diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f698695..4378a48 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,30 +27,16 @@ jobs: web: name: Web Frontend Check - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main + # Phase 5: full frontend pipeline — format:check, build (tsc -b included), + # vitest, Playwright chromium smoke. Replaces the old typecheck-only job + # plus the standalone Web Tests job. No lint script yet (run-lint: false). + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main with: working-directory: web cache-dependency-path: web/package-lock.json node-version: "24" - run-cdk-synth: false - run-conventions-check: false - - web-test: - name: Web Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: web - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: npm - cache-dependency-path: web/package-lock.json - - run: npm ci - - run: npm test + run-lint: false + required-scripts: "format:check,build,test,test:e2e" python: name: Python Lint @@ -106,7 +92,7 @@ jobs: # required check would otherwise never report on this repo. ci: name: ci / ci - needs: [dotnet, web, web-test, python, python-test, mobile, infra] + needs: [dotnet, web, python, python-test, mobile, infra] if: always() runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 88de624..58e6fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ api/src/ProposalSystem.Api/Data/verified-sites.json *.zip .claude/worktrees/ .claude/agents/ + +# Playwright (web e2e smoke) +web/test-results/ +web/playwright-report/ diff --git a/README.md b/README.md index 4e2974d..16b5f48 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,12 @@ npx cdk synth ### CI (on pull request to main) -Seven parallel jobs calling org reusable workflows: +Six parallel jobs calling org reusable workflows: | Job | Workflow | What it checks | |---|---|---| -| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (104 xUnit tests) | -| Web Frontend Check | `ci-typescript-cdk.yaml` | TypeScript typecheck for web | -| Web Tests | `ci-typescript-cdk.yaml` | vitest suite (26 tests — auth, interceptors, components) | +| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (xUnit) | +| Web Frontend Check | `ci-typescript-frontend.yaml` | Prettier `format:check`, build (includes `tsc -b`), vitest suite, Playwright chromium smoke (dev-login → proposal list, API mocked) | | Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile | | Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ | | Python Tests | `ci-python-sam.yaml` | pytest suite (19 tests — pdf-generate, suggestions handlers) | diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..7c08b73 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist +coverage +node_modules +package-lock.json +playwright-report +test-results diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..554f2a3 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..7fa5f1d --- /dev/null +++ b/web/e2e/smoke.spec.ts @@ -0,0 +1,113 @@ +// Browser smoke: dev-login → dashboard shell → proposal list (Phase 5, web CI). +// The API is fully mocked via route interception so the smoke runs in pure CI +// with no backend — it exercises the login flow, auth context, protected +// routing, the app shell, and list rendering end-to-end in a real browser. +import { test, expect, type Page } from '@playwright/test'; + +function fakeJwt(): string { + const b64 = (o: object) => + Buffer.from(JSON.stringify(o)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${b64({ alg: 'HS256' })}.${b64({ + sub: 'e2e-user', + exp: Math.floor(Date.now() / 1000) + 3600, + })}.e2e-signature`; +} + +const authUser = { + id: 'e2e-user', + email: 'adam@seahavenind.com', + displayName: 'Adam Moussa', + role: 'SysAdmin', + token: fakeJwt(), +}; + +const proposals = [ + { + id: 'p-1', + proposalNumber: 'PROP-2026-001', + customerName: 'Harbor Point Marina', + workOrderNumber: 'WO-1001', + serviceCategory: 'HVAC', + priority: 'Standard', + status: 'InReview', + totalBidAmount: 12500, + submittedAt: '2026-07-01T12:00:00Z', + submittedByName: 'Adam Moussa', + assignedAdminName: null, + }, + { + id: 'p-2', + proposalNumber: 'PROP-2026-002', + customerName: 'Seaside Property Group', + workOrderNumber: 'WO-1002', + serviceCategory: 'Electrical', + priority: 'Urgent', + status: 'Approved', + totalBidAmount: 4300, + submittedAt: '2026-07-05T09:30:00Z', + submittedByName: 'Adam Moussa', + assignedAdminName: 'Sarah Chen', + }, +]; + +async function mockApi(page: Page) { + // Match on pathname, not a '**/api/**' glob: vite dev serves app modules from + // paths like /src/lib/api/client.ts, which such a glob would swallow. + // Registered first = matched last (Playwright checks routes newest-first): + // generic fallback so nothing the shell fetches hangs or hard-errors. + await page.route( + (url) => url.pathname.startsWith('/api/'), + (route) => route.fulfill({ json: {} }), + ); + + await page.route( + (url) => url.pathname === '/api/auth/dev-login', + (route) => route.fulfill({ json: authUser }), + ); + await page.route( + (url) => url.pathname === '/api/proposals/stats', + (route) => + route.fulfill({ json: { totalCount: 2, inReviewCount: 1, approvedCount: 1, sentCount: 0 } }), + ); + await page.route( + (url) => url.pathname === '/api/proposals' && url.searchParams.has('page'), + (route) => + route.fulfill({ + json: { items: proposals, totalCount: proposals.length, page: 1, pageSize: 12 }, + }), + ); +} + +test('dev-login lands on the dashboard and the proposal list renders', async ({ page }) => { + await mockApi(page); + + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText('Welcome back')).toBeVisible(); + + await page.getByRole('button', { name: /SysAdmin/ }).click(); + + // Authenticated shell: topbar user chip + dashboard content. + await expect(page).toHaveURL('/'); + await expect(page.getByText('Adam Moussa').first()).toBeVisible(); + await expect(page.getByText('Recent Proposals')).toBeVisible(); + + // Proposal list via sidebar nav. + await page.getByText('My Proposals', { exact: true }).click(); + await expect(page).toHaveURL(/\/proposals$/); + await expect(page.getByRole('heading', { name: 'My Proposals' })).toBeVisible(); + await expect(page.getByText('Harbor Point Marina')).toBeVisible(); + await expect(page.getByText('Seaside Property Group')).toBeVisible(); +}); + +test('unauthenticated visits are bounced to the login card', async ({ page }) => { + await mockApi(page); + + await page.goto('/proposals'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText('Welcome back')).toBeVisible(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 14003a6..b6fe602 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -35,6 +36,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "prettier": "^3.9.5", "typescript": "~7.0.2", "vite": "^8.1.3", "vitest": "^4.1.10" @@ -934,6 +936,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "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", @@ -3170,6 +3188,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "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/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -3199,6 +3264,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/web/package.json b/web/package.json index 944f470..615fa99 100644 --- a/web/package.json +++ b/web/package.json @@ -7,8 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -31,6 +34,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -38,6 +42,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "prettier": "^3.9.5", "typescript": "~7.0.2", "vite": "^8.1.3", "vitest": "^4.1.10" diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..1737064 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,27 @@ +// Browser smoke config — mirrors shoc-frontend-new/playwright.config.ts. +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev -- --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: false, + timeout: 120_000, + }, +});