Skip to content

Commit 7691a3b

Browse files
[DC-274] Finalize Playwright suite (#631)
* test: finalize Playwright coverage for portal, CO Keycloak login, and enrollment checker * test(DC-274): assert dashboard renders before Amharic snapshot and mock 422 for address suggestions --------- Co-authored-by: Nicholas Campanini <necampanini@icloud.com>
1 parent 5d8d5d7 commit 7691a3b

24 files changed

Lines changed: 961 additions & 38 deletions

.github/workflows/playwright-e2e.yaml

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
outputs:
2727
backend: ${{ steps.decide.outputs.backend }}
2828
frontend: ${{ steps.decide.outputs.frontend }}
29+
enrollment: ${{ steps.decide.outputs.enrollment }}
2930
steps:
3031
- name: Filter changed paths (PRs only)
3132
id: filter
@@ -56,6 +57,11 @@ jobs:
5657
- 'packages/**'
5758
- 'pnpm-lock.yaml'
5859
- 'pnpm-workspace.yaml'
60+
enrollment:
61+
- 'apps/portal/src/SEBT.EnrollmentChecker.Web/**'
62+
- 'packages/**'
63+
- 'pnpm-lock.yaml'
64+
- 'pnpm-workspace.yaml'
5965
6066
- name: Decide which areas run
6167
id: decide
@@ -64,13 +70,16 @@ jobs:
6470
INFRA: ${{ steps.filter.outputs.infra }}
6571
BACKEND: ${{ steps.filter.outputs.backend }}
6672
FRONTEND: ${{ steps.filter.outputs.frontend }}
73+
ENROLLMENT: ${{ steps.filter.outputs.enrollment }}
6774
run: |
6875
if [ "$EVENT_NAME" != "pull_request" ] || [ "$INFRA" = "true" ]; then
6976
echo "backend=true" >> "$GITHUB_OUTPUT"
7077
echo "frontend=true" >> "$GITHUB_OUTPUT"
78+
echo "enrollment=true" >> "$GITHUB_OUTPUT"
7179
else
7280
echo "backend=${BACKEND:-false}" >> "$GITHUB_OUTPUT"
7381
echo "frontend=${FRONTEND:-false}" >> "$GITHUB_OUTPUT"
82+
echo "enrollment=${ENROLLMENT:-false}" >> "$GITHUB_OUTPUT"
7483
fi
7584
7685
# E2E drives the web app alone (MSW mocks the API), so backend-only changes
@@ -139,6 +148,49 @@ jobs:
139148
retention-days: 7
140149

141150
# Integration E2E drives the full stack (real API + web), so it reacts to both areas.
151+
# The enrollment checker's suite mocks its API at the browser level, so it
152+
# only needs the Next dev server the Playwright config boots itself.
153+
e2e-checker:
154+
name: Enrollment Checker E2E (CO)
155+
needs: changes
156+
if: needs.changes.outputs.enrollment == 'true'
157+
runs-on: ubuntu-latest
158+
timeout-minutes: 15
159+
160+
steps:
161+
- name: Checkout code
162+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
163+
164+
- name: Setup pnpm
165+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
166+
with:
167+
version: "10"
168+
169+
- name: Setup Node.js
170+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
171+
with:
172+
node-version: "24"
173+
cache: "pnpm"
174+
175+
- name: Install dependencies
176+
run: pnpm install --frozen-lockfile --prefer-offline
177+
178+
- name: Install Playwright browsers
179+
run: cd apps/portal/src/SEBT.EnrollmentChecker.Web && pnpm exec playwright install --with-deps chromium
180+
181+
- name: Run enrollment checker E2E tests
182+
env:
183+
CI: true
184+
run: pnpm ci:test:e2e:checker
185+
186+
- name: Upload Playwright report
187+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
188+
if: ${{ always() }}
189+
with:
190+
name: playwright-report-checker
191+
path: apps/portal/src/SEBT.EnrollmentChecker.Web/playwright-report/
192+
retention-days: 7
193+
142194
e2e-integration-dc:
143195
name: Playwright Integration E2E (DC)
144196
needs: changes
@@ -312,6 +364,189 @@ jobs:
312364
path: apps/portal/src/SEBT.Portal.Web/playwright-report/
313365
retention-days: 7
314366

367+
# Full-stack Colorado sign-in through the compose Keycloak stand-in for
368+
# myColorado: a real OIDC credential dance, not cookie injection. Keycloak
369+
# runs via docker compose (not a service container) because the realm import
370+
# needs the checked-out repo's fixture files.
371+
e2e-integration-co:
372+
name: Playwright Integration E2E (CO)
373+
needs: changes
374+
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true'
375+
runs-on: ubuntu-latest
376+
timeout-minutes: 25
377+
378+
services:
379+
mssql:
380+
image: mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04@sha256:45a1a9d13ca5574cf8e0fe4ae73ab77248b66d9c3132ac9658fb6c16dd72a8af
381+
env:
382+
ACCEPT_EULA: Y
383+
MSSQL_SA_PASSWORD: YourStrong@Passw0rd
384+
ports:
385+
- 1433:1433
386+
options: >-
387+
--health-cmd="/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -Q 'SELECT 1' -C -b -o /dev/null || exit 1"
388+
--health-interval=10s
389+
--health-timeout=5s
390+
--health-retries=10
391+
--health-start-period=10s
392+
393+
steps:
394+
- name: Checkout code
395+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
396+
397+
- name: Start Keycloak (myColorado stand-in)
398+
run: |
399+
docker compose --profile keycloak up -d keycloak
400+
echo "Waiting for Keycloak discovery document..."
401+
for i in $(seq 1 60); do
402+
curl -sf http://localhost:8180/realms/sebt/.well-known/openid-configuration > /dev/null && echo "Keycloak ready" && break
403+
sleep 2
404+
done
405+
curl -sf http://localhost:8180/realms/sebt/.well-known/openid-configuration > /dev/null || (echo "Keycloak failed to start" && docker logs sebt_keycloak | tail -50 && exit 1)
406+
407+
- name: Setup .NET
408+
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
409+
with:
410+
global-json-file: global.json
411+
412+
- name: Setup pnpm
413+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
414+
with:
415+
version: "10"
416+
417+
- name: Setup Node.js
418+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
419+
with:
420+
node-version: "24"
421+
cache: "pnpm"
422+
423+
- name: Install dependencies
424+
run: pnpm install --frozen-lockfile --prefer-offline
425+
426+
- name: Build backend
427+
run: ./.github/workflows/scripts/build-backend.sh --configuration Release
428+
429+
- name: Build frontend for integration E2E
430+
env:
431+
STATE: co
432+
NEXT_PUBLIC_STATE: co
433+
run: ./.github/workflows/scripts/build-frontend.sh --production --skip-install
434+
435+
- name: Install Playwright browsers
436+
run: cd apps/portal/src/SEBT.Portal.Web && pnpm exec playwright install --with-deps chromium
437+
438+
- name: Install dotnet-ef
439+
run: |
440+
dotnet tool install --global dotnet-ef --version "$CI_DOTNET_EF_VERSION"
441+
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
442+
443+
- name: Initialize SQL Server Database
444+
env:
445+
ASPNETCORE_ENVIRONMENT: Development
446+
STATE: co
447+
run: |
448+
set -euo pipefail
449+
export PATH="$PATH:$HOME/.dotnet/tools"
450+
451+
CONN="Server=localhost,1433;Database=SebtPortal;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
452+
SA_PASSWORD='YourStrong@Passw0rd'
453+
SQLCMD_IMAGE="$CI_MSSQL_IMAGE"
454+
455+
run_sqlcmd() {
456+
docker run --rm --network host "$SQLCMD_IMAGE" \
457+
/opt/mssql-tools18/bin/sqlcmd -S localhost,1433 -U sa -P "$SA_PASSWORD" -C -b "$@"
458+
}
459+
460+
for i in $(seq 1 30); do
461+
if run_sqlcmd -Q 'SELECT 1' -o /dev/null 2>&1; then
462+
echo "SQL Server ready"
463+
break
464+
fi
465+
if [ "$i" -eq 30 ]; then
466+
echo "SQL Server did not become ready in time"
467+
exit 1
468+
fi
469+
sleep 1
470+
done
471+
472+
echo "Creating SebtPortal database if missing..."
473+
run_sqlcmd -Q "IF DB_ID(N'SebtPortal') IS NULL CREATE DATABASE [SebtPortal];"
474+
475+
for attempt in $(seq 1 3); do
476+
echo "Applying EF migrations (attempt ${attempt}/3)..."
477+
if dotnet ef database update \
478+
--project apps/portal/src/SEBT.Portal.Infrastructure/SEBT.Portal.Infrastructure.csproj \
479+
--startup-project apps/portal/src/SEBT.Portal.Api/SEBT.Portal.Api.csproj \
480+
--configuration Release \
481+
--no-build \
482+
--connection "$CONN"; then
483+
break
484+
fi
485+
if [ "$attempt" -eq 3 ]; then
486+
echo "EF migrations failed after 3 attempts"
487+
exit 1
488+
fi
489+
sleep 5
490+
done
491+
492+
- name: Run Playwright integration E2E tests
493+
env:
494+
CI: true
495+
E2E_FULL_STACK: "1"
496+
SKIP_WEB_SERVER: "1"
497+
STATE: co
498+
NEXT_PUBLIC_STATE: co
499+
BACKEND_URL: http://localhost:5280
500+
# No Mailpit in the CO stack — the checker/DC OTP flow doesn't run here.
501+
MAILPIT_API_URL: ""
502+
KEYCLOAK_DISCOVERY_URL: http://localhost:8180/realms/sebt/.well-known/openid-configuration
503+
ASPNETCORE_ENVIRONMENT: Development
504+
ConnectionStrings__DefaultConnection: "Server=localhost,1433;Database=SebtPortal;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
505+
JwtSettings__SecretKey: "ci-e2e-jwt-secret-at-least-32-characters-long"
506+
IdentifierHasher__SecretKey: "ci-e2e-identifier-hasher-key-32chars"
507+
# The CO overlay expects TLS Redis; the single-instance e2e stack uses
508+
# the in-memory stores instead.
509+
Redis__Host: ""
510+
Oidc__DiscoveryEndpoint: http://localhost:8180/realms/sebt/.well-known/openid-configuration
511+
Oidc__ClientId: sebt-portal
512+
Oidc__ClientSecret: sebt-portal-dev-secret
513+
Oidc__CallbackRedirectUri: http://localhost:3000/callback
514+
Oidc__CompleteLoginSigningKey: "ci-e2e-oidc-signing-key-at-least-32-chars"
515+
Oidc__StepUp__DiscoveryEndpoint: http://localhost:8180/realms/sebt/.well-known/openid-configuration
516+
Oidc__StepUp__ClientId: sebt-portal-stepup
517+
Oidc__StepUp__ClientSecret: sebt-portal-stepup-dev-secret
518+
PluginAssemblyPaths__0: "plugins-co"
519+
UseMockHouseholdData: "true"
520+
Cbms__UseMockResponses: "true"
521+
run: |
522+
# The CO overlay (IAL requirements, phone-based household ids, seeding)
523+
# ships as an example file; the env above overrides its Redis/OIDC bits.
524+
cp apps/portal/src/SEBT.Portal.Api/appsettings.co.example.json apps/portal/src/SEBT.Portal.Api/appsettings.co.json
525+
ASPNETCORE_ENVIRONMENT=Development dotnet run --project apps/portal/src/SEBT.Portal.Api --launch-profile http --configuration Release --no-build &
526+
(cd apps/portal/src/SEBT.Portal.Web && pnpm start) &
527+
echo "Waiting for API at http://localhost:5280/health..."
528+
for i in $(seq 1 90); do
529+
curl -sf --max-time 15 http://localhost:5280/health > /dev/null && echo "API ready" && break
530+
sleep 2
531+
done
532+
curl -sf --max-time 15 http://localhost:5280/health > /dev/null || (echo "API failed to start" && exit 1)
533+
echo "Waiting for web app at http://localhost:3000..."
534+
for i in $(seq 1 90); do
535+
curl -sf --max-time 15 http://localhost:3000 > /dev/null && echo "Web ready" && break
536+
sleep 2
537+
done
538+
curl -sf --max-time 15 http://localhost:3000 > /dev/null || (echo "Web failed to start" && exit 1)
539+
echo "Running Playwright integration E2E tests..."
540+
pnpm ci:test:e2e:integration:co
541+
542+
- name: Upload Playwright integration report
543+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
544+
if: ${{ always() }}
545+
with:
546+
name: playwright-integration-report-co
547+
path: apps/portal/src/SEBT.Portal.Web/playwright-report/
548+
retention-days: 7
549+
315550
# Pa11y runs the full stack (real API + web), so it reacts to both areas.
316551
a11y:
317552
name: Pa11y Accessibility Tests
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!-- BEGIN:nextjs-agent-rules -->
2+
3+
# This is NOT the Next.js you know
4+
5+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6+
7+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8+
9+
<!-- END:nextjs-agent-rules -->
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

apps/portal/src/SEBT.EnrollmentChecker.Web/e2e/enrollment.spec.ts

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,36 @@
11
import { expect, test } from '@playwright/test'
22

3+
import { completeCheckFlow, fillChildForm, makeResult, mockCheckResults, startCheckFlow } from './fixtures'
4+
35
test.describe('Enrollment checker happy path', () => {
4-
test('navigates from landing to results', async ({ page }) => {
5-
// Mock the enrollment check API so we don't need a live backend
6-
await page.route('**/api/enrollment/check', route =>
7-
route.fulfill({
8-
status: 200,
9-
contentType: 'application/json',
10-
body: JSON.stringify({
11-
results: [{ checkId: '1', firstName: 'Jane', lastName: 'Doe', dateOfBirth: '2015-04-12', status: 'Match' }]
12-
})
13-
})
14-
)
15-
16-
await page.goto('/')
17-
18-
// Landing page — click "Apply now"
19-
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
20-
await page.getByRole('button', { name: /apply now/i }).click()
6+
test('navigates from landing to enrolled results', async ({ page }) => {
7+
await mockCheckResults(page, [makeResult({ status: 'Match' })])
218

22-
// Disclaimer page
23-
await expect(page.url()).toContain('/disclaimer')
24-
await page.getByRole('button', { name: /continue/i }).click()
9+
await completeCheckFlow(page)
10+
11+
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
12+
// The enrolled child appears in the results summary with a portal link to
13+
// manage their benefits.
14+
await expect(page.getByText(/Jane/)).toBeVisible()
15+
await expect(page.getByTestId('portal-link')).toBeVisible()
16+
})
17+
18+
test('review page shows the entered child before submit', async ({ page }) => {
19+
await mockCheckResults(page, [makeResult()])
2520

26-
// Check page — fill three-field birthdate
27-
await expect(page.url()).toContain('/check')
28-
await page.getByRole('textbox', { name: /first name/i }).fill('Jane')
29-
await page.getByRole('textbox', { name: /last name/i }).fill('Doe')
30-
await page.getByLabel(/month/i).selectOption('4')
31-
await page.getByRole('textbox', { name: /day/i }).fill('12')
32-
await page.getByRole('textbox', { name: /year/i }).fill('2015')
21+
await startCheckFlow(page)
22+
await fillChildForm(page, { firstName: 'Jane', lastName: 'Doe', month: '4', day: '12', year: '2015' })
3323
await page.getByRole('button', { name: /continue/i }).click()
3424

35-
// Review page
36-
await expect(page.url()).toContain('/review')
25+
await page.waitForURL('**/review')
3726
await expect(page.getByText(/Jane Doe/i)).toBeVisible()
38-
await expect(page.getByText(/April, 12 2015/i)).toBeVisible()
39-
await page.getByRole('button', { name: /submit/i }).click()
40-
41-
// Results page
42-
await expect(page.url()).toContain('/results')
43-
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
27+
await expect(page.getByText(/April 12, 2015/i)).toBeVisible()
4428
})
4529

4630
test('back button returns from disclaimer to landing', async ({ page }) => {
4731
await page.goto('/disclaimer')
4832
await page.getByRole('button', { name: /back/i }).click()
49-
await expect(page.url()).toMatch(/\/$/)
33+
await expect(page).toHaveURL(/\/$/)
5034
})
5135

5236
test('/closed page renders', async ({ page }) => {

0 commit comments

Comments
 (0)