ci(web): Phase 5 — prettier check + Playwright smoke via org frontend workflow

Converts the web CI job from ci-typescript-cdk.yaml (typecheck only) to
ci-typescript-frontend.yaml: format:check, build (tsc -b included),
vitest, and a Playwright chromium smoke. Folds the standalone Web Tests
job into it (aggregator needs updated). Pure CI — no AWS secrets.

The smoke (e2e/smoke.spec.ts) drives dev-login → dashboard shell →
proposal list, plus the unauthenticated bounce, against a fully mocked
API (pathname-anchored route interception — a '**/api/**' glob would
swallow vite's /src/lib/api/* module URLs). Config mirrors SHOC's
playwright.config.ts (port 4173, chromium, dev-server webServer).

Prettier: singleQuote + printWidth 100 to match the existing codebase
style; lint intentionally not added (no ESLint config yet — run-lint
false, out of Phase 5 scope). rollback = revert this workflow file.
This commit is contained in:
Adam Moussa 2026-07-13 20:14:41 -04:00
parent 97cc26b03e
commit 191f710752
No known key found for this signature in database
9 changed files with 251 additions and 26 deletions

View file

@ -27,30 +27,16 @@ jobs:
web: web:
name: Web Frontend Check 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: with:
working-directory: web working-directory: web
cache-dependency-path: web/package-lock.json cache-dependency-path: web/package-lock.json
node-version: "24" node-version: "24"
run-cdk-synth: false run-lint: false
run-conventions-check: false required-scripts: "format:check,build,test,test:e2e"
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
python: python:
name: Python Lint name: Python Lint
@ -106,7 +92,7 @@ jobs:
# required check would otherwise never report on this repo. # required check would otherwise never report on this repo.
ci: ci:
name: ci / ci name: ci / ci
needs: [dotnet, web, web-test, python, python-test, mobile, infra] needs: [dotnet, web, python, python-test, mobile, infra]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

4
.gitignore vendored
View file

@ -69,3 +69,7 @@ api/src/ProposalSystem.Api/Data/verified-sites.json
*.zip *.zip
.claude/worktrees/ .claude/worktrees/
.claude/agents/ .claude/agents/
# Playwright (web e2e smoke)
web/test-results/
web/playwright-report/

View file

@ -114,13 +114,12 @@ npx cdk synth
### CI (on pull request to main) ### 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 | | Job | Workflow | What it checks |
|---|---|---| |---|---|---|
| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (104 xUnit tests) | | .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (xUnit) |
| Web Frontend Check | `ci-typescript-cdk.yaml` | TypeScript typecheck for web | | 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) |
| Web Tests | `ci-typescript-cdk.yaml` | vitest suite (26 tests — auth, interceptors, components) |
| Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile | | Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile |
| Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ | | 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) | | Python Tests | `ci-python-sam.yaml` | pytest suite (19 tests — pdf-generate, suggestions handlers) |

6
web/.prettierignore Normal file
View file

@ -0,0 +1,6 @@
dist
coverage
node_modules
package-lock.json
playwright-report
test-results

4
web/.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 100
}

113
web/e2e/smoke.spec.ts Normal file
View file

@ -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();
});

81
web/package-lock.json generated
View file

@ -28,6 +28,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -35,6 +36,7 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"prettier": "^3.9.5",
"typescript": "~7.0.2", "typescript": "~7.0.2",
"vite": "^8.1.3", "vite": "^8.1.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
@ -934,6 +936,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -3170,6 +3188,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@ -3199,6 +3264,22 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View file

@ -7,8 +7,11 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@ -31,6 +34,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -38,6 +42,7 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"prettier": "^3.9.5",
"typescript": "~7.0.2", "typescript": "~7.0.2",
"vite": "^8.1.3", "vite": "^8.1.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"

27
web/playwright.config.ts Normal file
View file

@ -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,
},
});