Merge pull request #221 from Sea-Haven-Industries/dev
chore: promote current dev onto main
52
.cursor/rules/seahaven-admin.mdc
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
description: SeaHaven Admin — IrisLoan architecture, selective rebuild conventions
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# SeaHaven Admin — IrisLoan conventions
|
||||
|
||||
## Stack
|
||||
|
||||
- Vite SPA + React Router 7, TypeScript strict, Tailwind v4 + MUI
|
||||
- HTTP: Ky with `credentials: "include"` (httpOnly cookies)
|
||||
- Server state: TanStack Query; forms: React Hook Form + Zod
|
||||
- **Do not use** in new code: Next.js, Redux, axios, `apiUtil.js`, `tokenUtility.js`
|
||||
|
||||
## Target structure (`src/`)
|
||||
|
||||
```
|
||||
api/ domain/<feature>/ app/ infra/query-key/ lib/ hooks/
|
||||
components/ui|common|layout/ config/menu.ts providers/ main.tsx
|
||||
```
|
||||
|
||||
- `@/` alias for imports; new files in **kebab-case** (`use-debounce.ts`, `query-key.ts`)
|
||||
- `pages/` is **reference only** for endpoints and business rules — delete after delivery in `domain/` + `app/`
|
||||
|
||||
## HTTP and auth
|
||||
|
||||
- Centralize paths in `api/api-paths.ts` and responses in `api/handle-api-response.ts`
|
||||
- Auth via AuthProvider + cookies; do not create Redux slices for session
|
||||
|
||||
## State and data
|
||||
|
||||
- Cache and mutations: React Query + `infra/query-key/query-key.ts`
|
||||
- Client defaults in `lib/query/query-client.ts`
|
||||
|
||||
## Forms and UI
|
||||
|
||||
- Zod schemas in `domain/<feature>/schemas/`; thin views in `app/`
|
||||
- Utility Tailwind + MUI components; **do not** import `site.css` in new code
|
||||
|
||||
## Feature implementation flow
|
||||
|
||||
1. Consult legacy in `pages/<feature>/` (endpoints, fields, rules)
|
||||
2. Create `domain/<feature>/` (schemas, hooks, use-cases)
|
||||
3. Thin view in `app/` + route in `app/routes.tsx`
|
||||
4. Delete `pages/<feature>/` when the new feature is ready
|
||||
|
||||
## Tooling
|
||||
|
||||
- Strict ESLint only on new architecture `.ts/.tsx`; legacy in `pages/` ignored
|
||||
- Commits: Conventional Commits (`feat(scope): subject`)
|
||||
|
||||
Reference: `docs/ARCHITECTURE_PLAN.md`
|
||||
3
.env
|
|
@ -1,2 +1 @@
|
|||
REACT_APP_API_URL=
|
||||
|
||||
VITE_API_URL=
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Development API URL (uses proxy)
|
||||
REACT_APP_API_URL=/api
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Development API Target (for proxy)
|
||||
REACT_APP_API_TARGET=http://localhost:5141
|
||||
VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
21
.env.example
|
|
@ -1,13 +1,20 @@
|
|||
# API Configuration
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# Backend API Base URL (without /api suffix)
|
||||
# For local development, this should point to your ASP.NET Core API
|
||||
#REACT_APP_API_URL=http://localhost:5000
|
||||
# Backend API base URL (Ky prefixUrl). API route paths do NOT include /api — it comes from here.
|
||||
# Development: use /api (Vite proxies /api to VITE_API_TARGET)
|
||||
#VITE_API_URL=/api
|
||||
|
||||
# For production, update to your production API URL
|
||||
REACT_APP_API_URL=https://api.seahavenindustries.com
|
||||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Note: The /api prefix is added in the service files
|
||||
# Example: ${REACT_APP_API_URL}/api/Authentication/login
|
||||
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
|
||||
# Leave blank to disable telemetry for that build.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# Sentry environment label (development, staging, production). Blank falls back
|
||||
# to the Vite build mode.
|
||||
#VITE_SENTRY_ENVIRONMENT=
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
|
|
@ -1,2 +1,10 @@
|
|||
# Production API URL
|
||||
REACT_APP_API_URL=http://console.seahavenind.com/api
|
||||
# Production API base — the SPA calls the backend directly over HTTPS.
|
||||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
|
||||
# Public browser configuration; the DSN is public by design and baked into the
|
||||
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
|
||||
# and future prod jobs override them per environment.
|
||||
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
|
|
|||
3
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# All changes require an approving review from the internal-dev team.
|
||||
# Enforced via the org "main branch protection" ruleset (require_code_owner_review).
|
||||
* @Sea-Haven-Industries/internal-dev
|
||||
73
.github/renovate.json
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Playwright image tag in CI must match @playwright/test",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:v(?<currentValue>\\d+\\.\\d+\\.\\d+)-noble"
|
||||
],
|
||||
"datasourceTemplate": "npm",
|
||||
"depNameTemplate": "@playwright/test",
|
||||
"versioningTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": ["Group non-major Terraform provider updates"],
|
||||
"matchManagers": ["terraform"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "terraform minor and patch"
|
||||
},
|
||||
{
|
||||
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
|
||||
"matchUpdateTypes": ["major", "replacement"],
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": [
|
||||
"Do not open Playwright PRs until approved; image and upload-artifact share a Node runtime"
|
||||
],
|
||||
"matchPackageNames": ["@playwright/test"],
|
||||
"dependencyDashboardApproval": true,
|
||||
"groupName": "playwright"
|
||||
},
|
||||
{
|
||||
"description": ["Keep MUI packages together"],
|
||||
"matchPackageNames": ["@mui/**"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"groupName": "mui"
|
||||
},
|
||||
{
|
||||
"description": ["Keep React and its type packages together"],
|
||||
"matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"groupName": "react"
|
||||
},
|
||||
{
|
||||
"description": ["Keep FullCalendar packages together"],
|
||||
"matchPackageNames": ["@fullcalendar/**"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"groupName": "fullcalendar"
|
||||
},
|
||||
{
|
||||
"description": ["Do not bump package.json engines"],
|
||||
"matchDepTypes": ["engines"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": ["Do not bump packageManager"],
|
||||
"matchDepTypes": ["packageManager"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": ["Keep TanStack Query packages on one version"],
|
||||
"matchPackageNames": ["@tanstack/**"],
|
||||
"groupName": "tanstack query"
|
||||
}
|
||||
]
|
||||
}
|
||||
106
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
name: Frontend checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
push:
|
||||
branches: [main, dev, staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test
|
||||
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
governance:
|
||||
# Repo-owned guarantee that every frontend quality gate runs from this
|
||||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
|
||||
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
|
||||
# and GitHub workflow shell). If the reusable workflow is later confirmed
|
||||
# to run every gate, this job can be slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
# push-> the previous commit on the branch (github.event.before)
|
||||
# manual -> dev, for exact-head recovery runs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve governance comparison ref
|
||||
id: governance-ref
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
|
||||
base="${PR_BASE_SHA}"
|
||||
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
|
||||
# the remote run will see.
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.12"
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o actionlint.tar.gz \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf actionlint.tar.gz actionlint
|
||||
sudo mv actionlint /usr/local/bin/actionlint
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e:visual
|
||||
- name: Upload visual diff artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-regression-diffs
|
||||
path: |
|
||||
test-results/visual
|
||||
playwright-report-visual
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
148
.github/workflows/deploy-staging.yml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
name: Deploy staging
|
||||
|
||||
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
|
||||
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
|
||||
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
|
||||
# job to declare `environment: staging` and run in this repo, with the
|
||||
# non-secret role ARN pinned below (created by the staging stack itself).
|
||||
#
|
||||
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
|
||||
# No secrets are used — OIDC + the static role ARN are the only credentials.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to staging
|
||||
# Deploy only the exact staging branch ref, never a tag or other ref
|
||||
# (workflow_dispatch can be invoked from arbitrary refs).
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve governance comparison ref
|
||||
id: governance-ref
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
|
||||
# (npm@11.16.0) matches this CI environment.
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
- name: Assume staging deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||
# label (process env overrides the dev values committed in
|
||||
# .env.production), syncs to the staging bucket, and invalidates
|
||||
# CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name shoc-frontend-staging \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
DIST_DOMAIN="$(stack_output DistributionDomainName)"
|
||||
SITE_URL="$(stack_output SiteUrl)"
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
|
||||
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
|
||||
|
||||
aws s3api head-bucket --bucket "${BUCKET}"
|
||||
echo "Bucket exists."
|
||||
# The distribution is proven to exist and serve by the HTTPS check
|
||||
# below: the custom domain is an alias to this distribution, and the
|
||||
# deploy role deliberately carries no cloudfront:GetDistribution
|
||||
# (least privilege; the dev template is shared and must not drift).
|
||||
|
||||
if grep -Rq "api.dev.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
|
||||
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Built assets carry no dev API URL."
|
||||
grep -Rq "api.staging.seahaven.com" dist/
|
||||
echo "Built assets reference the staging API URL."
|
||||
|
||||
# Verify the actual post-invalidation HTML and its referenced assets,
|
||||
# not only the local build or a generic endpoint response.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
|
||||
break
|
||||
fi
|
||||
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
test -s "${remote_dir}/index.html"
|
||||
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
|
||||
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
|
||||
| sort -u > "${remote_dir}/asset-paths.txt"
|
||||
test -s "${remote_dir}/asset-paths.txt"
|
||||
while IFS= read -r asset_path; do
|
||||
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
|
||||
>> "${remote_dir}/assets.txt"
|
||||
done < "${remote_dir}/asset-paths.txt"
|
||||
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
|
||||
echo "::error::Deployed assets contain the dev API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
|
||||
echo "Deployed staging assets reference only the staging API URL."
|
||||
300
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
name: Deploy dev content
|
||||
|
||||
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
|
||||
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
|
||||
# group, and invalidation. Push-to-dev stays off until
|
||||
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
|
||||
#
|
||||
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
|
||||
# re-run those gates on pull requests, pushes, or workflow_dispatch.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
name: Deploy shoc-frontend-new-dev through Terraform
|
||||
if: >
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
|
||||
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
TF_CLOUD_ORGANIZATION: seahaven
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
SITE_URL: https://dev.seahaven.com
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build SPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Rq "localhost:5141" dist/; then
|
||||
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Assign immutable release identity
|
||||
id: release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
prefix="releases/${version_label}"
|
||||
{
|
||||
echo "version_label=${version_label}"
|
||||
echo "prefix=${prefix}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Sentry release is shoc-frontend@${GITHUB_SHA} via VITE_APP_COMMIT_SHA,
|
||||
# distinct from the S3/Terraform version_label.
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Read previous release pointer
|
||||
id: pointer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
|
||||
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
|
||||
|
||||
- name: Upload immutable release prefix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${{ steps.release.outputs.prefix }}"
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${prefix}/index.html"
|
||||
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
|
||||
|
||||
- name: Capture previous served hash
|
||||
id: previous-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
|
||||
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub CD
|
||||
id: discard-vcs
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- name: Create Terraform release run
|
||||
id: release-run
|
||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
env:
|
||||
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-dev
|
||||
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
|
||||
|
||||
- name: Read Terraform release plan counts
|
||||
id: release-plan
|
||||
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
plan: ${{ steps.release-run.outputs.plan_id }}
|
||||
|
||||
- name: Reject non-release resource counts
|
||||
env:
|
||||
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
|
||||
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
|
||||
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Flags must match check-terraform-release-plan.py. Pointer `before`
|
||||
# and origin-ID-set stability are asserted from the plan JSON.
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.release.outputs.version_label }}" \
|
||||
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
|
||||
|
||||
- name: Discard release run when the guard fails
|
||||
if: failure() && steps.release-run.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.release-run.outputs.run_id }}
|
||||
comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform release run
|
||||
id: release-apply
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.release-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied release run as success
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.release-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.release-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront release
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
|
||||
run: bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Restore previous release on failure
|
||||
if: failure()
|
||||
id: rollback-prepare
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev="${{ steps.pointer.outputs.live_current }}"
|
||||
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
|
||||
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
|
||||
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub rollback
|
||||
id: rollback-discard-vcs
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- name: Create Terraform rollback run
|
||||
id: rollback-run
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
env:
|
||||
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-dev
|
||||
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
|
||||
|
||||
- name: Read Terraform rollback plan counts
|
||||
id: rollback-plan
|
||||
if: failure() && steps.rollback-run.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
plan: ${{ steps.rollback-run.outputs.plan_id }}
|
||||
|
||||
- name: Reject non-release rollback counts
|
||||
id: rollback-count-guard
|
||||
if: failure() && steps.rollback-plan.outcome == 'success'
|
||||
env:
|
||||
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
|
||||
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
|
||||
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform rollback plan
|
||||
id: rollback-json-guard
|
||||
if: failure() && steps.rollback-count-guard.outcome == 'success'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
|
||||
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
|
||||
|
||||
- name: Discard rollback run when the guard fails
|
||||
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||
comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform rollback run
|
||||
id: rollback-apply
|
||||
if: failure() && steps.rollback-json-guard.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied rollback run as success
|
||||
id: rollback-apply-result
|
||||
if: failure() && steps.rollback-apply.outcome != 'skipped'
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.rollback-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront rollback
|
||||
if: failure() && steps.rollback-apply-result.outcome == 'success'
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
export EXPECTED_INDEX_SHA256="${expected_sha}"
|
||||
bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Live-state summary
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: bash scripts/summarize-cloudfront-live-state.sh
|
||||
43
.github/workflows/terraform-isolation.yaml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Terraform isolation
|
||||
|
||||
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
|
||||
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
|
||||
# would report required checks as success and could merge a failing SHA.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes Terraform infrastructure together with
|
||||
# deployable application code (scripts/check-terraform-isolation.mjs). A
|
||||
# merge that does both queues an HCP VCS run and a content release at the
|
||||
# same time, and the two race for the workspace lock. The
|
||||
# `terraform-isolation-override` label is the reviewed exception. This
|
||||
# job is unconditional so adding or removing that label always reads the
|
||||
# current label set; a previous green check does not survive removal.
|
||||
name: Terraform and application changes are isolated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Check changed files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
|
||||
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
|
||||
26
.gitignore
vendored
|
|
@ -8,8 +8,14 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright-report-visual
|
||||
/blob-report
|
||||
/playwright/.cache
|
||||
|
||||
# production
|
||||
/dist
|
||||
/build
|
||||
|
||||
# misc
|
||||
|
|
@ -24,4 +30,22 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
db.txt
|
||||
seed-data.sql
|
||||
/backend
|
||||
/backend
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx lint-staged
|
||||
14
.prettierignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
dist
|
||||
build
|
||||
node_modules
|
||||
coverage
|
||||
playwright-report-visual
|
||||
package-lock.json
|
||||
*.tsbuildinfo
|
||||
|
||||
# Legacy — format incrementally when touched (lint-staged)
|
||||
src/pages/**
|
||||
src/**/*.js
|
||||
src/**/*.jsx
|
||||
src/**/*.css
|
||||
!src/styles/globals.css
|
||||
15
.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.mdc",
|
||||
"options": {
|
||||
"parser": "markdown"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
81
AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# AGENTS.md — frontend mandatory conventions
|
||||
|
||||
This file is the binding entry point for **every contributor — human or AI coding
|
||||
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
|
||||
frontend). It makes the validated React/TypeScript conventions mandatory and
|
||||
points to the operational documents and executable gates that enforce them.
|
||||
|
||||
Read these before writing or reviewing code. They override generic "best
|
||||
practice" suggestions from any agent or model. This repository contract may
|
||||
strengthen, but never weaken, the workspace-level `AGENTS.md`.
|
||||
|
||||
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
|
||||
command, and the no-false-pass guarantees.
|
||||
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
|
||||
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
|
||||
data access, security, maintainability) with evidence and thresholds.
|
||||
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
|
||||
contract (exact-head review, board regression inventory, behavior-based
|
||||
testing, security/performance/Big-O review, no style-only comments).
|
||||
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
|
||||
detailed rationale for the conditional-rendering and typography rules.
|
||||
|
||||
## One command to run every gate
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||
`governance` job, so a green local run mirrors CI.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
These are already enforced by lint/build or the governance script. Disabling,
|
||||
baselining, or per-line-suppressing them is forbidden (see exceptions below).
|
||||
|
||||
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
|
||||
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
|
||||
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
|
||||
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
|
||||
the `vp-error` class outside `Text` are rejected.
|
||||
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
|
||||
do not silence it.
|
||||
- **Hooks correctness** — the `react-hooks` recommended rules (including
|
||||
`exhaustive-deps`) run under the zero-warnings gate.
|
||||
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
|
||||
has a frozen per-file cap that may only decrease.
|
||||
- **Changed-file maintainability** — changed TS/TSX must meet
|
||||
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
|
||||
|
||||
## How to add / change a convention
|
||||
|
||||
1. Land it **green**: a new or tightened rule must ship with the codebase passing
|
||||
it (a migration in the same change), not as a warning-only backlog.
|
||||
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
|
||||
new baseline debt or raise a frozen cap.
|
||||
3. Document the rule, its threshold, and its evidence in
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||
|
||||
## Exceptions and the ADR process
|
||||
|
||||
Exceptions are **not granted by disabling a rule inline**. To deviate:
|
||||
|
||||
1. Record an **Architecture Decision Record** under `docs/adr/`
|
||||
(`NNNN-title.md`: context, decision, consequences, alternatives).
|
||||
2. Existing grandfathered entries may only be removed or have their caps
|
||||
reduced. New entries and cap increases fail the governance gate.
|
||||
3. Get it reviewed like any other change. The ADR + baseline entry is the
|
||||
auditable record; an `eslint-disable` comment is not.
|
||||
|
||||
## Toolchain (do not change without an ADR)
|
||||
|
||||
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
|
||||
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
|
||||
`packageManager`, invoked through corepack). **No new dependencies without an
|
||||
ADR** — prefer the libraries already established (see
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
|
||||
data access).
|
||||
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
|
||||
|
||||
Mandatory React/TypeScript conventions for this repository. Each rule lists how
|
||||
it is enforced. Detailed rationale for rendering and typography lives in
|
||||
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
|
||||
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
|
||||
means it is a review-enforced convention backed by the
|
||||
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
|
||||
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
|
||||
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
|
||||
semantics and TypeScript narrowing with `count > 0`, `value != null`.
|
||||
`{count && <X/>}` renders `0` and is rejected by the type-aware
|
||||
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
|
||||
the approved guard forms.)
|
||||
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
|
||||
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
|
||||
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
|
||||
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
|
||||
`vp-error` class on any non-`Text` element is rejected
|
||||
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
|
||||
bypass the static check.
|
||||
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
|
||||
omitted from `TextProps` so the variant contract (element, tone, live region)
|
||||
cannot be overridden. Use `as` and `tone`.
|
||||
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
|
||||
with `when`; mount `error` (assertive `alert`) on demand.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
- **MUST** use the libraries already established: **React Hook Form** for field
|
||||
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
|
||||
Query** for server reads and mutations (pending/error state, invalidation,
|
||||
retries).
|
||||
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
|
||||
second form convention increases cognitive and dependency cost. Replacing
|
||||
React Hook Form requires an approved repo-wide migration ADR with measured
|
||||
benefits, a codemod/migration plan, and removal of the superseded dependency.
|
||||
- **SHOULD** keep file upload selection/validation in a focused component and
|
||||
drive upload progress/errors/retry through a TanStack Query mutation, not in a
|
||||
route-sized page.
|
||||
|
||||
## State ownership (bounded)
|
||||
|
||||
- **MUST** keep query loading/error/empty state adjacent to the query result;
|
||||
mutation pending/error state in the component that initiated the mutation.
|
||||
- **MUST** compose focused state components instead of accumulating unrelated
|
||||
booleans in a page. Route pages coordinate sections and navigation; reusable
|
||||
sections own their interaction details.
|
||||
- **SHOULD** render errors inline with accessible feedback (`Text
|
||||
variant="error"`); reserve toasts for cross-page outcomes.
|
||||
|
||||
## Data access
|
||||
|
||||
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
|
||||
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
|
||||
break caching and invalidation. (Review-enforced; a future lint rule is
|
||||
tracked as a gap.)
|
||||
- **MUST NOT** materialize large server collections into client state and then
|
||||
filter/sort them in the component when the server (or a memoized, virtualized
|
||||
layer) should own it. Prefer server-side filtering/pagination; if client-side
|
||||
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
|
||||
- **MUST** invalidate the right query keys after a mutation so caches do not
|
||||
show stale data.
|
||||
|
||||
## Security
|
||||
|
||||
- **MUST NOT** surface raw server error payloads, stack traces, or internal
|
||||
identifiers to end users. Map server errors to a safe user-facing message
|
||||
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
|
||||
(Review-enforced.)
|
||||
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
|
||||
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
|
||||
never put a secret in a `VITE_` variable.
|
||||
|
||||
## Hooks, performance, and Big-O
|
||||
|
||||
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
|
||||
under the zero-warnings gate.
|
||||
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
|
||||
data, key lists stably, and prefer server-side filtering for large sets
|
||||
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
|
||||
only when measured to matter (no speculative memoization).
|
||||
|
||||
## Maintainability (measured thresholds)
|
||||
|
||||
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
|
||||
These are conservative code-shape proxies for review focus. File length is not
|
||||
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
|
||||
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
|
||||
judgment.
|
||||
|
||||
| Metric | Threshold | How enforced |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
|
||||
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
|
||||
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
|
||||
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
|
||||
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
|
||||
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
|
||||
|
||||
**Why these are changed-file ratchets, not whole-repo errors:** measured against
|
||||
the current codebase, the maintainability thresholds surface **72 violations
|
||||
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
|
||||
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
|
||||
legacy without a migration. Instead:
|
||||
|
||||
- The **5 oversized files** are explicit grandfathered debt with frozen
|
||||
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
|
||||
grow; new entries and cap increases fail.
|
||||
- The **complexity/function-length/params/depth** rules apply to **changed**
|
||||
TS/TSX on a PR/push, so new and modified code must comply while untouched
|
||||
legacy is not blocked. Bring legacy into compliance when you next touch it.
|
||||
|
||||
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
|
||||
entries) as debt is paid down. Never raise them to make a change pass.
|
||||
83
QUALITY_GATES.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# QUALITY_GATES.md — executable frontend gates
|
||||
|
||||
The single command that runs **every** gate, locally and in CI:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: Terraform import-plan and release-plan checkers, isolation tests,
|
||||
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
|
||||
workflow shell checks. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
|
||||
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
|
||||
backlog; rules ship green (see AGENTS.md → How to add a convention).
|
||||
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
|
||||
when type services are unavailable rather than silently claiming safety.
|
||||
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
|
||||
entry, global cap increase, per-file cap increase, or growth beyond a frozen
|
||||
legacy cap fails. Only cap reductions and entry removals are allowed.
|
||||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **Terraform gates never touch live state** — `terraform init -backend=false
|
||||
-lockfile=readonly` and `validate` run offline; the plan checker is tested
|
||||
against synthetic plan JSON. Real import and controlled-update plans from HCP
|
||||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`).
|
||||
- **The isolation gate re-evaluates on label changes** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
|
||||
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
|
||||
default pull-request types, so adding or removing the label re-checks
|
||||
the current labels without starting a new Frontend checks run. Removing
|
||||
the label fails a mixed PR that had previously passed with the override.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow.
|
||||
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
|
||||
on pull requests, fails when Terraform infrastructure and application code
|
||||
change together. Label add/remove re-runs only this workflow.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
210
README.md
|
|
@ -1,70 +1,204 @@
|
|||
# Getting Started with Create React App
|
||||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Available Scripts
|
||||
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
|
||||
portal, uplifts, and related admin features. This is the selective rebuild of
|
||||
the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
|
||||
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
||||
|
||||
In the project directory, you can run:
|
||||
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||
|
||||
### `npm start`
|
||||
## Architecture
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
Static SPA hosting on AWS, owned by HCP Terraform
|
||||
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
|
||||
`dist/` from a private S3 bucket using a current/previous origin group;
|
||||
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
|
||||
proxy at the CDN — the backend allows CORS).
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
```mermaid
|
||||
graph LR
|
||||
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
|
||||
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions] -->|OIDC upload releases/*| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
|
||||
```
|
||||
|
||||
### `npm test`
|
||||
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
|
||||
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||
architecture plan for the keep/discard migration matrix).
|
||||
|
||||
### `npm run build`
|
||||
## AWS Resources
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
|
||||
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
No Lambdas, queues, or databases — this stack is static hosting only.
|
||||
|
||||
### `npm run eject`
|
||||
## Configuration
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
### Secrets
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
|
||||
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
|
||||
repo secret** is:
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
| Secret | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
## Learn More
|
||||
| Variable | Description | Dev value |
|
||||
| ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `VITE_API_URL` | Ky API base prefix, **baked into the build** at `vite build` | `/api` (dev server) / `https://api.dev.seahaven.com/api` (production build) |
|
||||
| `VITE_API_TARGET` | Dev-proxy target for `/api` (Vite dev server only) | `http://localhost:5141` |
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
`VITE_API_URL` supplies the full API prefix — route paths in `API_PATHS` do
|
||||
**not** include `/api`. Absolute values **must end with `/api`**;
|
||||
`vite.config.ts` enforces this via `config/api-url-contract.ts` and fails the
|
||||
build otherwise. See [`.env.example`](.env.example),
|
||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
Pinned hosting constants (domain, certificate ARN, hosted zone) live in
|
||||
[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf).
|
||||
|
||||
### Code Splitting
|
||||
## Local Development
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
Requirements: Node.js ≥ 22.22.1 (CI/CD run Node 24), npm 11.16.0 (pinned via
|
||||
`packageManager`).
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.example .env # then set VITE_API_URL=/api for local dev
|
||||
npm run dev # Vite dev server on port 3000, proxies /api → VITE_API_TARGET
|
||||
```
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
||||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
### Making a Progressive Web App
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform and content-CD gates.
|
||||
|
||||
### Advanced Configuration
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
`npm run build`) before pushing to catch type errors early.
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
## Contributing
|
||||
|
||||
### Deployment
|
||||
- Branch from `dev` with a kebab-case description and a prefix matching the
|
||||
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
||||
- Commit messages follow
|
||||
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||
rejects anything else at commit time.
|
||||
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
|
||||
a green CI run and an approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- A PR that changes `terraform/**` may not also change application code (the
|
||||
`terraform-isolation` CI job); ship Terraform in its own PR.
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
|
||||
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
## Deployment
|
||||
|
||||
### `npm run build` fails to minify
|
||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`/`staging`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the content-CD guards) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **Terraform isolation**
|
||||
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml))
|
||||
— fails a PR that mixes `terraform/**` with application code, so a Terraform
|
||||
merge never races a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev`, and push-to-`dev` when
|
||||
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
|
||||
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
|
||||
`.release/current`, both origin paths, and the invalidation action. Verify
|
||||
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
|
||||
a live-state summary.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run HCP Terraform workspace
|
||||
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
|
||||
Staging hosting stays on the existing CloudFormation stack until SH-287.
|
||||
|
||||
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
|
||||
content publisher only.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
|
||||
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
route) loads directly and API calls succeed.
|
||||
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
|
||||
still serves the previous `index.html` hash. Read the live-state summary
|
||||
before assuming the site is down.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
|
||||
on this repo; dispatching the workflow from another branch is rejected by
|
||||
design.
|
||||
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
|
||||
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
|
||||
an HCP Terraform run that a human confirms or discards before the next
|
||||
content release (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
85
REVIEW_AND_PR_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
|
||||
|
||||
Every PR is reviewed against this contract, by humans and by review agents. The
|
||||
goal is high-signal review: catch real behavior, security, performance, and
|
||||
regression problems — not re-lint what the gates already enforce.
|
||||
|
||||
## 1. Review the exact head
|
||||
|
||||
- **MUST** review the diff at the **current PR head**, not a stale checkout.
|
||||
Re-pull before reviewing if new commits landed; stale approvals are dismissed
|
||||
on push by branch protection.
|
||||
- **MUST** read the full diff of every changed file, including renames and
|
||||
generated/mapper code, not only the "interesting" components.
|
||||
|
||||
## 2. Board-backed regression inventory
|
||||
|
||||
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
|
||||
all visible tickets for this area, not only the current ticket. Behavior that
|
||||
is Done / QA-approved / released is **protected scope**.
|
||||
- **MUST** treat a plausible regression against protected behavior as a
|
||||
**Blocker** until disproven with repo evidence (the accepting tests, the linked
|
||||
PR/release, and a targeted check of the changed code paths).
|
||||
- **MUST NOT** approve if board access or the relevant inventory is missing —
|
||||
say so and block rather than infer a pass. A missing ticket link alone is not
|
||||
a blocker, but known acceptance criteria must still be traced.
|
||||
|
||||
The current Jira workflow has no `Ready for QA` transition. Merged work remains
|
||||
in the documented pre-QA status until QA evidence supports `Done`; do not invent
|
||||
a status or mark unverified work Done.
|
||||
|
||||
## 3. Behavior-based testing
|
||||
|
||||
- **MUST** test through **public behavior** (rendered output, user interactions,
|
||||
query/mutation outcomes), not internal implementation details. Prefer
|
||||
`@testing-library` queries and user-event flows; assert what users observe.
|
||||
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
|
||||
governance script) executes — validate tooling by running the real gates
|
||||
(`npm run verify`), not with assertion-free unit tests.
|
||||
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
|
||||
error/empty state, and any boundary the change introduces.
|
||||
|
||||
## 4. Security review
|
||||
|
||||
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
|
||||
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
|
||||
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
|
||||
`VITE_*` variable carries a secret (it is baked into the bundle).
|
||||
- **SHOULD** check untrusted input is validated (Zod) before use and that
|
||||
dangerously-set HTML / unescaped server strings are not introduced.
|
||||
|
||||
## 5. Performance and Big-O review
|
||||
|
||||
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
|
||||
collections, re-filtering/sorting on every render, unbounded list rendering
|
||||
without virtualization.
|
||||
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
|
||||
the correct keys (no stale cache, no redundant refetch storms).
|
||||
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
|
||||
unstable identities passed to memoized children.
|
||||
|
||||
## 6. No style-only comments
|
||||
|
||||
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||
gates; review is for behavior, correctness, security, and architecture.
|
||||
- **MUST NOT** put Jira issue keys or ticket titles in source comments, JSDoc,
|
||||
or test names (for example `(SH-183)`). Ticket identity belongs in the PR,
|
||||
commit message, and branch — not in the code. Flag and request removal if a
|
||||
diff adds them.
|
||||
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
|
||||
evidence-based convention in these docs, and offer a concrete fix or a
|
||||
targeted question. Use GitHub suggestion blocks when safe.
|
||||
|
||||
## 7. Review close-out
|
||||
|
||||
A review is complete when it records, briefly:
|
||||
|
||||
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
|
||||
"no findings".
|
||||
2. Open questions and their owner.
|
||||
3. Which of the above checks were run, and any that were skipped (and why).
|
||||
4. Residual risk, if approving.
|
||||
|
||||
Do not write a monolithic review body or a validation transcript into the PR
|
||||
surface; keep comments inline and high-signal.
|
||||
1
commitlint.config.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
41
config/api-url-contract.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertAbsoluteApiUrlHasApiSegment } from "./api-url-contract";
|
||||
|
||||
describe("assertAbsoluteApiUrlHasApiSegment", () => {
|
||||
it("allows relative /api", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("/api")).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows empty or undefined", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment(undefined)).not.toThrow();
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("")).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows absolute URL ending with /api", () => {
|
||||
expect(() =>
|
||||
assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com/api"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects absolute URL without /api", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com")).toThrow(
|
||||
/must end with \/api/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".env.example contract", () => {
|
||||
it("documents an absolute VITE_API_URL that includes /api", () => {
|
||||
const envExample = readFileSync(path.resolve(process.cwd(), ".env.example"), "utf8");
|
||||
const match = envExample.match(/^VITE_API_URL=(.+)$/m);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
const value = match![1]!.trim();
|
||||
|
||||
if (value.startsWith("http")) {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment(value)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
21
config/api-url-contract.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Absolute VITE_API_URL values must end with this segment (API_PATHS omit /api). */
|
||||
export const ABSOLUTE_API_URL_SUFFIX = /\/api\/?$/;
|
||||
|
||||
/**
|
||||
* Fails fast when an absolute VITE_API_URL omits the /api segment.
|
||||
* Relative values (e.g. /api) are valid and skipped.
|
||||
*/
|
||||
export function assertAbsoluteApiUrlHasApiSegment(apiUrl: string | undefined): void {
|
||||
const trimmed = apiUrl?.trim();
|
||||
if (!trimmed?.startsWith("http")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ABSOLUTE_API_URL_SUFFIX.test(trimmed)) {
|
||||
throw new Error(
|
||||
`VITE_API_URL absolute URLs must end with /api (got: ${trimmed}). ` +
|
||||
"API route paths do not include /api — set VITE_API_URL to the full API base, e.g. https://api.example.com/api. " +
|
||||
"See README.md and .env.example.",
|
||||
);
|
||||
}
|
||||
}
|
||||
33
config/pwa-manifest.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { readFileSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const manifest = JSON.parse(readFileSync(path.join(root, "public/manifest.json"), "utf-8")) as {
|
||||
name: string;
|
||||
short_name: string;
|
||||
theme_color: string;
|
||||
icons: Array<{ src: string }>;
|
||||
};
|
||||
const indexHtml = readFileSync(path.join(root, "index.html"), "utf-8");
|
||||
|
||||
describe("PWA install metadata", () => {
|
||||
it("identifies the app as Sea Haven, not template branding", () => {
|
||||
expect(manifest.name).toContain("Sea Haven");
|
||||
expect(manifest.short_name).not.toBe("React App");
|
||||
expect(manifest.name).not.toContain("Create React App");
|
||||
});
|
||||
|
||||
it("uses the design-system theme color, matching index.html", () => {
|
||||
expect(manifest.theme_color).toBe("#1c75bc");
|
||||
expect(indexHtml).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||
});
|
||||
|
||||
it("only references icon files that exist in public/", () => {
|
||||
for (const icon of manifest.icons) {
|
||||
expect(existsSync(path.join(root, "public", icon.src)), `missing public/${icon.src}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
26
config/sentry-release.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
|
||||
|
||||
describe("resolveSentryRelease", () => {
|
||||
it("maps the exact commit to the frontend release", () => {
|
||||
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
expect(resolveSentryRelease(sha, true)).toEqual({
|
||||
commitSha: sha.toLowerCase(),
|
||||
release: `shoc-frontend@${sha.toLowerCase()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails CI when the commit identity is missing or malformed", () => {
|
||||
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
|
||||
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
|
||||
});
|
||||
|
||||
it("labels non-CI builds without a commit as local development", () => {
|
||||
expect(resolveSentryRelease(undefined, false)).toEqual({
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
config/sentry-release.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const SENTRY_SERVICE_NAME = "shoc-frontend";
|
||||
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function resolveSentryRelease(
|
||||
candidate: string | undefined,
|
||||
ci: boolean,
|
||||
): {
|
||||
commitSha: string;
|
||||
release: string;
|
||||
} {
|
||||
const commitSha = candidate?.trim();
|
||||
|
||||
if (commitSha && COMMIT_SHA.test(commitSha)) {
|
||||
return {
|
||||
commitSha: commitSha.toLowerCase(),
|
||||
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ci) {
|
||||
throw new Error(
|
||||
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
};
|
||||
}
|
||||
102
docs/ARCHITECTURE_PLAN.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Architecture Plan — Sea Haven (Selective Rebuild)
|
||||
|
||||
> Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI
|
||||
|
||||
Mirror document of the Cursor plan. Strategy: **reuse knowledge, discard legacy code** — do not normalize/refactor the current project.
|
||||
|
||||
---
|
||||
|
||||
## Strategy in one sentence
|
||||
|
||||
Build `api/` + `domain/` + `app/` from scratch; use `pages/` only as **endpoint and business rule reference**; **delete** each legacy folder when the new feature is ready.
|
||||
|
||||
---
|
||||
|
||||
## Matrix: Keep vs Discard
|
||||
|
||||
### Keep (extract logic → new code)
|
||||
|
||||
| Legacy | Keep | Destination |
|
||||
| ------------------------------------- | -------------------------------- | ------------------------------------------------ |
|
||||
| `lib/api/services.js` | ASP.NET paths + response parsing | `api/api-paths.ts`, `api/handle-api-response.ts` |
|
||||
| `pages/*/api.js` | Real endpoints | `api/api-paths.ts` |
|
||||
| `constants/queryKeys.js` | Entities | `infra/query-key/query-key.ts` |
|
||||
| `hooks/useDebounce.js`, `useModal.js` | Hooks | `hooks/*.ts` |
|
||||
| `hooks/api/usePMSchedules.js` | RQ pattern | Template `domain/*/use-cases/` |
|
||||
| `lib/queryClient.js` | Cache defaults | `lib/query/query-client.ts` |
|
||||
| `App.js`, `Sidebar.js` | Routes and menu | `app/routes.tsx`, `config/menu.ts` |
|
||||
| `pages/workorders/` (list) | Business flow | Reference for POC |
|
||||
|
||||
### Discard (delete without refactoring)
|
||||
|
||||
- `apiUtil.js`, `services/api.js`, `lib/api/client.js`
|
||||
- `tokenUtility.js`, `authService.js`, Redux (`app/store.js`, slices)
|
||||
- `usePaginatedList.js`, entire `pages/` folder (after replacement)
|
||||
- `Site.Layout.js`, `setupProxy.js`, `site.css`, per-feature CSS, Font Awesome
|
||||
- Forms `*FormPage.js` (rewrite with RHF + Zod)
|
||||
- `SharedTable.js`, `ActionBar.js` (recreate if adapting is costly)
|
||||
|
||||
### Defer (later)
|
||||
|
||||
- `pages/vendor-portal/` → `domain/vendor-portal/`
|
||||
- `pages/calendar/` → new FullCalendar wrapper
|
||||
|
||||
---
|
||||
|
||||
## Feature priority
|
||||
|
||||
Full matrix (waves, dependencies, LOC, done criteria): **[`FEATURE_PRIORITIZATION.md`](FEATURE_PRIORITIZATION.md)** · TS config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
||||
|
||||
| Wave | Scope |
|
||||
| ----------------- | --------------------------------------------------------------------------- |
|
||||
| **0 POC** | auth → work-orders (list) |
|
||||
| **1 must-have** | work-orders (full), dashboard, settings/dropdowns |
|
||||
| **2** | accounts, locations, employees |
|
||||
| **3 should-have** | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
|
||||
| **4 later** | calendar, vendor-portal, pm-schedules, assets, reports/documents |
|
||||
|
||||
Stakeholder decision: **auth + work-orders first**; POC = auth + work-orders list (not pm-schedules).
|
||||
|
||||
---
|
||||
|
||||
## Target structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/
|
||||
├── domain/<feature>/
|
||||
├── infra/query-key/
|
||||
├── lib/
|
||||
├── components/ui/ + common/
|
||||
├── app/
|
||||
├── config/menu.ts
|
||||
├── hooks/
|
||||
├── providers/
|
||||
└── main.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Goal |
|
||||
| ----- | ------------------------------------------------------------------------------------- |
|
||||
| **0** | Complete foundation (Ky, routes, layout, AuthProvider, tooling) — Vite/TS/Tailwind ok |
|
||||
| **1** | Ky + httpOnly auth cookies; delete legacy HTTP stacks |
|
||||
| **2** | Rewrite must-have features; delete `pages/<feature>/` per delivery |
|
||||
| **3** | UI shell + RHF+Zod forms |
|
||||
| **4** | Remove `pages/`, Redux, legacy CSS; Docker + CI + Vitest |
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Complete foundation (`api/api.ts`, `routes.tsx`, AuthProvider, layout shell)
|
||||
2. `api/api-paths.ts` (extract from `lib/api/services.js`)
|
||||
3. POC: `domain/auth` + login → delete `pages/auth/`
|
||||
4. POC: `domain/work-orders` (list) → delete list portion of `pages/workorders/`
|
||||
5. Cookie contract with backend
|
||||
|
||||
---
|
||||
|
||||
See full plan with diagrams in `.cursor/plans/migração_arquitetura_irisloan_cf39e1a6.plan.md`.
|
||||
137
docs/DESIGN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Sea Haven Design System
|
||||
|
||||
Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`).
|
||||
|
||||
## Stack
|
||||
|
||||
- **MUI-only** — no Radix/shadcn; MUI is the sole component library
|
||||
- **Tailwind v4** — semantic utility classes from CSS tokens
|
||||
- **Fonts** — Montserrat (headings), DM Sans (body), JetBrains Mono (code) via `@fontsource`
|
||||
|
||||
## Token source of truth
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| `src/styles/theme.css` | All `:root` CSS variables + `@theme inline` Tailwind aliases |
|
||||
| `src/styles/fonts.css` | `@fontsource` imports |
|
||||
| `src/styles/typography.css` | Base `html`, `body`, `h1–h6`, `.text-caption`, `.text-label` |
|
||||
| `src/styles/motion.css` | `--duration-fast`, `--duration-normal`, `--ease-default` |
|
||||
| `src/styles/globals.css` | Entry: imports all above + `@custom-variant dark` |
|
||||
|
||||
## Key tokens
|
||||
|
||||
### Brand & surfaces
|
||||
|
||||
| Token | Value | Usage |
|
||||
| -------------------- | --------- | -------------------------- |
|
||||
| `--primary` | `#1c75bc` | Buttons, links, focus ring |
|
||||
| `--primary-hover` | `#155a92` | Hover states |
|
||||
| `--foreground` | `#262262` | Primary text |
|
||||
| `--background` | `#ffffff` | Cards, inputs |
|
||||
| `--color-bg-page` | `#f9fafb` | Page background |
|
||||
| `--border` | `#dfe3ea` | Dividers, outlines |
|
||||
| `--muted-foreground` | `#58595b` | Secondary text |
|
||||
|
||||
### Layout
|
||||
|
||||
| Token | Value |
|
||||
| ----------------------------- | ------- |
|
||||
| `--spacing-sidebar` | `220px` |
|
||||
| `--spacing-sidebar-collapsed` | `56px` |
|
||||
| `--spacing-topbar` | `64px` |
|
||||
|
||||
### Typography scale
|
||||
|
||||
| Token | Size |
|
||||
| ---------------- | ---- |
|
||||
| `--text-xs` | 11px |
|
||||
| `--text-sm` | 12px |
|
||||
| `--text-base-sm` | 13px |
|
||||
| `--text-base` | 14px |
|
||||
| `--text-md` | 16px |
|
||||
| `--text-lg` | 18px |
|
||||
| `--text-xl` | 22px |
|
||||
| `--text-2xl` | 28px |
|
||||
|
||||
### Elevation
|
||||
|
||||
| Token | Usage |
|
||||
| ------------- | ------------------- |
|
||||
| `--shadow-sm` | Cards, subtle depth |
|
||||
| `--shadow-md` | Dropdowns, panels |
|
||||
| `--shadow-lg` | Dialogs, modals |
|
||||
|
||||
### Work order badges (immutable)
|
||||
|
||||
Type, status, and completion-doc tokens (`--type-*`, `--status-*`, `--doc-*`) must not be changed — see `src/components/domain/`.
|
||||
|
||||
## Typography hierarchy
|
||||
|
||||
| Element | Font | Weight | Size |
|
||||
| --------------- | ---------- | ------ | -------------- |
|
||||
| `h1` | Montserrat | 800 | 28px |
|
||||
| `h2` | Montserrat | 700 | 22px |
|
||||
| `h3` | Montserrat | 600 | 18px |
|
||||
| `h4–h6` | Montserrat | 600 | 16–13px |
|
||||
| Body | DM Sans | 400 | 14px |
|
||||
| `.text-caption` | DM Sans | 400 | 12px |
|
||||
| `.text-label` | DM Sans | 600 | 11px uppercase |
|
||||
|
||||
MUI typography variants in `src/lib/theme/mui-theme.ts` mirror this hierarchy.
|
||||
|
||||
## MUI + Tailwind usage
|
||||
|
||||
### Prefer themed MUI in forms and data tables
|
||||
|
||||
MUI components inherit Sea Haven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
|
||||
|
||||
### Prefer semantic Tailwind in layout shells
|
||||
|
||||
```tsx
|
||||
<div className="bg-background text-foreground border-border" />
|
||||
<div className="text-muted-foreground" />
|
||||
<div className="bg-primary text-primary-foreground" />
|
||||
```
|
||||
|
||||
### Compose classes with `cn()`
|
||||
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
<div className={cn("flex gap-2", isActive && "bg-sidebar-accent")} />;
|
||||
```
|
||||
|
||||
### Shared UI wrappers
|
||||
|
||||
| Component | Path | Pattern |
|
||||
| ------------ | ------------------------------- | ---------------------------- |
|
||||
| `Button` | `components/ui/button.tsx` | MUI Button + variant presets |
|
||||
| `Badge` | `components/ui/badge.tsx` | MUI Chip |
|
||||
| `PageHeader` | `components/ui/page-header.tsx` | PATTERN_001 |
|
||||
| `EmptyState` | `components/ui/empty-state.tsx` | PATTERN_003 |
|
||||
| `FormField` | `components/ui/form-field.tsx` | PATTERN_004 |
|
||||
|
||||
### Domain badges
|
||||
|
||||
| Component | Path |
|
||||
| -------------------------------------- | ------------------------------------ |
|
||||
| `TypeBadge` | `components/domain/type-badge.tsx` |
|
||||
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
|
||||
| `DocBadge` | `components/domain/doc-badge.tsx` |
|
||||
|
||||
## Legacy → Sea Haven mapping
|
||||
|
||||
| Legacy | Sea Haven | Consumption |
|
||||
| ------------------------- | ------------------------------ | -------------------------------- |
|
||||
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
|
||||
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
|
||||
| `#f4f6f7` / `--bg` | `--color-bg-page` | Body, page shell |
|
||||
| `#d9dde0` / `--border` | `--border` | Dividers, inputs |
|
||||
| `#fbfbfb` sidebar | `--color-sidebar-bg` | App sidebar |
|
||||
| Segoe UI 13px | DM Sans 14px | Body typography |
|
||||
| Bootstrap calendar colors | `--primary`, `--success`, etc. | `calendar-utils.ts` |
|
||||
| `#1a3a5c` login header | `--color-header-bg-start` | Login branding strip |
|
||||
|
||||
## Dark mode
|
||||
|
||||
`@custom-variant dark (&:is(.dark *))` is configured in `globals.css`. Full dark token set is a future enhancement; light mode is the current default.
|
||||
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Design System Migration Log
|
||||
|
||||
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Sea Haven DS tokens. Strategy: **MUI-only**.
|
||||
|
||||
## Phase 1 — Foundation
|
||||
|
||||
| File | Action | Reason |
|
||||
| --------------------------- | ---------- | --------------------------------------------------------------------- |
|
||||
| `src/styles/theme.css` | Created | SoT: 131+ Sea Haven tokens + layout/typography/elevation extensions |
|
||||
| `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` |
|
||||
| `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles |
|
||||
| `src/styles/motion.css` | Created | Duration and easing tokens |
|
||||
| `src/styles/globals.css` | Refactored | Import chain + `@custom-variant dark`; removed legacy `@theme` tokens |
|
||||
| `src/lib/utils.ts` | Created | `cn()` with clsx + tailwind-merge |
|
||||
| `src/lib/theme/css-vars.ts` | Created | `getCssVar()` helper for MUI bridge |
|
||||
| `package.json` | Modified | Added `@fontsource/*`, `clsx`, `tailwind-merge` |
|
||||
|
||||
## Phase 2 — MUI Bridge
|
||||
|
||||
| File | Action | Reason |
|
||||
| ---------------------------- | -------- | --------------------------------------------------------------- |
|
||||
| `src/lib/theme/mui-theme.ts` | Expanded | Full palette, typography, shape, shadows, 8 component overrides |
|
||||
|
||||
## Phase 3 — App Shell
|
||||
|
||||
| File | Action | Reason |
|
||||
| ----------------------------------------- | -------- | -------------------------------------------------- |
|
||||
| `src/components/layout/app-sidebar.tsx` | Created | MUI Drawer + sidebar tokens; replaces `Sidebar.js` |
|
||||
| `src/components/layout/app-topbar.tsx` | Created | MUI AppBar + header gradient; replaces `Topbar.js` |
|
||||
| `src/app/(protected)/_layout.tsx` | Modified | Uses new layout components |
|
||||
| `src/main.tsx` | Modified | Removed `index.css` import |
|
||||
| `src/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Sea Haven CSS vars |
|
||||
| `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell |
|
||||
| `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` |
|
||||
| `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` |
|
||||
| `src/styles/FormPage.css` | Deleted | Unused (no imports in codebase) |
|
||||
|
||||
## Phase 4 — Shared UI
|
||||
|
||||
| File | Action | Reason |
|
||||
| ------------------------------------------ | -------- | -------------------------------------------- |
|
||||
| `src/components/ui/button.tsx` | Created | MUI Button wrapper |
|
||||
| `src/components/ui/badge.tsx` | Created | MUI Chip wrapper |
|
||||
| `src/components/ui/page-header.tsx` | Created | PATTERN_001 |
|
||||
| `src/components/ui/empty-state.tsx` | Created | PATTERN_003 |
|
||||
| `src/components/ui/form-field.tsx` | Created | PATTERN_004 |
|
||||
| `src/components/domain/type-badge.tsx` | Created | WO type tokens |
|
||||
| `src/components/domain/status-badge.tsx` | Created | WO status tokens + API aliases |
|
||||
| `src/components/domain/doc-badge.tsx` | Created | Completion doc tokens (MUI icons) |
|
||||
| `src/app/(protected)/workorders/index.tsx` | Modified | `WorkOrderStatusBadge` replaces generic Chip |
|
||||
|
||||
## Phase 5 — Page refactor
|
||||
|
||||
| File | Action | Reason |
|
||||
| ---------------------------------------------------------------------- | -------- | ------------------------------------------ |
|
||||
| `src/app/(auth)/login.tsx` | Modified | `#1a3a5c` → `var(--color-header-bg-start)` |
|
||||
| `src/app/(protected)/dashboard.tsx` | Modified | `border-gray-*` → `border-border` |
|
||||
| `src/app/(protected)/index.tsx` | Modified | Same as dashboard |
|
||||
| `src/app/(protected)/settings/dropdowns.tsx` | Modified | Semantic sidebar + active states |
|
||||
| `src/app/(protected)/settings/task-templates.tsx` | Modified | Semantic borders/text |
|
||||
| `src/components/common/settings-nav.tsx` | Modified | `text-primary`, `border-border` |
|
||||
| `src/components/common/calendar/calendar-utils.ts` | Modified | Bootstrap hex → Sea Haven CSS vars |
|
||||
| `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes |
|
||||
| `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes |
|
||||
| `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars |
|
||||
| `src/app/(protected)/workorders/_components/dispatch-create-modal.tsx` | Modified | Semantic borders/hover |
|
||||
| `src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx` | Modified | Semantic borders |
|
||||
| `src/app/(protected)/workorders/[id].tsx` | Modified | Semantic borders |
|
||||
| `src/app/(protected)/vendor-pos/[id].tsx` | Modified | Semantic borders |
|
||||
|
||||
## Phase 6 — Docs & validation
|
||||
|
||||
| File | Action | Reason |
|
||||
| --------------------------------- | ------- | ------------------------------- |
|
||||
| `docs/DESIGN_SYSTEM.md` | Created | Token reference and usage guide |
|
||||
| `docs/DESIGN_SYSTEM_MIGRATION.md` | Created | This file |
|
||||
|
||||
## Visual impact summary
|
||||
|
||||
- Primary color: `#0c4f6f` → `#1c75bc` (expected, documented)
|
||||
- Body font: Segoe UI 13px → DM Sans 14px
|
||||
- Sidebar: `#fbfbfb` → `#f6f8fb` with Sea Haven active/hover states
|
||||
- Header: flat blue gradient → Sea Haven multi-stop gradient
|
||||
|
||||
## Not migrated (intentional)
|
||||
|
||||
- CRUD list pages using default MUI theming — inherit via `mui-theme.ts` without JSX changes
|
||||
- `src/app/v/_components/signature-pad.tsx` — vendor portal isolated; minor `#111` stroke remains
|
||||
160
docs/FEATURE_PRIORITIZATION.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Feature Prioritization Matrix — Sea Haven
|
||||
|
||||
> Source of truth for legacy → `domain/` + `app/` migration order.
|
||||
> Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
||||
|
||||
---
|
||||
|
||||
## Stakeholder decisions
|
||||
|
||||
| Decision | Detail |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| Top priority | **`auth`** and **`work-orders`** |
|
||||
| End-to-end POC | **`auth` + work-orders (list)** — validate Ky, React Query, layout shell and AuthProvider |
|
||||
| Other features | Flexible order, guided by technical dependencies (waves below) |
|
||||
| POC **does not** include | `pm-schedules` (deferred to Wave 4) |
|
||||
|
||||
---
|
||||
|
||||
## Full matrix
|
||||
|
||||
| Order | Feature | Tier | Wave | Legacy folder | Routes | LOC ~ | Dependencies |
|
||||
| ----- | ------------------------- | ----------- | ---- | ----------------------------------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------- |
|
||||
| 1 | auth | POC | 0 | `pages/auth/` | `/login` | 513 | — |
|
||||
| 2 | work-orders (list) | POC | 0 | `pages/workorders/` (list) | `/workorders` | 4.3k\* | auth |
|
||||
| 3 | work-orders (form + view) | must-have | 1 | `pages/workorders/` | `/workorders/new`, `/workorders/:id`, `/workorders/edit/:id` | 4.3k\* | auth, work-orders (list), settings/dropdowns, accounts, locations, employees |
|
||||
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
|
||||
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
|
||||
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
|
||||
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
|
||||
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
|
||||
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
|
||||
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
|
||||
| 11 | uplifts | should-have | 3 | `pages/uplifts/` | `/uplifts` | 317 | auth, work-orders (full) |
|
||||
| 12 | follow-ups | should-have | 3 | `pages/followup/` | `/followups`, `/followups/new`, `/followups/edit/:id` | 1.3k | auth, employees, accounts, locations |
|
||||
| 13 | contacts | should-have | 3 | `pages/contacts/` | `/contacts`, `/contacts/new`, `/contacts/edit/:id` | 1.1k | auth |
|
||||
| 14 | settings/task-templates | should-have | 3 | `pages/settings/` (TaskTemplates) | `/settings/task-templates` | 866\* | auth, work-orders (full) |
|
||||
| 15 | calendar | later | 4 | `pages/calendar/` | `/calendar`, `/calendar/new`, `/calendar/edit/:id` | 1.9k | auth |
|
||||
| 16 | vendor-portal | later | 4 | `pages/vendor-portal/` | `/v/:token`, `/v/:token/dashboard`, `/v/:token/pos`, `/v/:token/dispatch/:id` | 1.4k | auth, vendors |
|
||||
| 17 | pm-schedules | later | 4 | `pages/PmSchedule/` | `/pmschedules`, `/pmschedules/new`, `/pmschedules/edit/:id` | 1.1k | auth |
|
||||
| 18 | assets | later | 4 | `pages/assets/` | `/assets`, `/assets/new`, `/assets/edit/:id` | 638 | auth, accounts |
|
||||
| 19 | reports / documents | later | 4 | — (no implementation) | `/reports`, `/documents` (menu only) | — | auth |
|
||||
|
||||
\* LOC for the entire folder; POC scope uses only the list submodule.
|
||||
|
||||
### Legacy complexity (reference)
|
||||
|
||||
| Feature | Complexity | Notes |
|
||||
| ------------- | ---------- | --------------------------------------------------------- |
|
||||
| auth | Low | Replace Redux/`tokenUtility` with AuthProvider + cookies |
|
||||
| dashboard | Medium | Real KPIs via `GET /Dashboard/Stats` |
|
||||
| work-orders | **High** | Monolithic view; dispatch, checklist, signoff |
|
||||
| contacts | High | Legacy form **stub** (save TODO) — rewrite with RHF + Zod |
|
||||
| employees | High | Large form |
|
||||
| vendor-portal | High | URL token auth + signature |
|
||||
| calendar | High | FullCalendar wrapper |
|
||||
| pm-schedules | Medium | Legacy API bugs (`PmSchedule/Create` vs `Save`) |
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph wave0 [Wave0_POC]
|
||||
auth[auth]
|
||||
woList[work_orders_list]
|
||||
end
|
||||
subgraph wave1 [Wave1_MustHave]
|
||||
woFull[work_orders_full]
|
||||
dash[dashboard]
|
||||
dropdowns[settings_dropdowns]
|
||||
end
|
||||
subgraph wave2 [Wave2_Dependencies]
|
||||
accounts[accounts]
|
||||
locations[locations]
|
||||
employees[employees]
|
||||
end
|
||||
subgraph wave3 [Wave3_ShouldHave]
|
||||
vendors[vendors]
|
||||
vendorPos[vendor_pos]
|
||||
uplifts[uplifts]
|
||||
followups[follow_ups]
|
||||
contacts[contacts]
|
||||
settingsTpl[task_templates]
|
||||
end
|
||||
subgraph wave4 [Wave4_Later]
|
||||
calendar[calendar]
|
||||
vendorPortal[vendor_portal]
|
||||
pmSched[pm_schedules]
|
||||
assets[assets]
|
||||
reports[reports_documents]
|
||||
end
|
||||
auth --> woList
|
||||
woList --> woFull
|
||||
dropdowns --> woFull
|
||||
accounts --> woFull
|
||||
locations --> woFull
|
||||
employees --> woFull
|
||||
woFull --> vendors
|
||||
vendors --> vendorPortal
|
||||
woFull --> vendorPos
|
||||
woFull --> uplifts
|
||||
```
|
||||
|
||||
### Waves (summary)
|
||||
|
||||
| Wave | Tier | Features |
|
||||
| ----- | ----------- | --------------------------------------------------------------------------- |
|
||||
| **0** | POC | auth, work-orders (list) |
|
||||
| **1** | must-have | work-orders (full), dashboard, settings/dropdowns |
|
||||
| **2** | must-have | accounts, locations, employees |
|
||||
| **3** | should-have | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
|
||||
| **4** | later | calendar, vendor-portal, pm-schedules, assets, reports/documents |
|
||||
|
||||
> `accounts` and `locations` can progress in parallel with the WO view if the list POC uses data already available in the backend; the order above is the minimum sequence for the full WO form.
|
||||
|
||||
---
|
||||
|
||||
## Legacy gaps and cleanup
|
||||
|
||||
| Item | Location | Migration action |
|
||||
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Duplicate dashboard | `pages/dashboard/` (mock prototype) | **Not routed** — ignore; use only `pages/Dashboard.js` as reference |
|
||||
| Orphan login | `pages/user/api.js` | Delete in Phase 1 (Ky + auth); duplicates `pages/auth/` flow |
|
||||
| Ghost menu | `Sidebar.js` → Reports `/reports`, Documents `/documents` | No routes or pages — implement from scratch (Wave 4) or remove from menu in `config/menu.ts` |
|
||||
| Incomplete contacts form | `pages/contacts/form/` | Do not port stub; rewrite with RHF + Zod in Wave 3 |
|
||||
| PM schedules API | `pages/PmSchedule/` | Document Create vs Save inconsistency; fix in rewrite |
|
||||
|
||||
---
|
||||
|
||||
## "Done" criteria per feature
|
||||
|
||||
A feature is **done** when all items below are met:
|
||||
|
||||
1. **`domain/<feature>/`** — Zod schemas, hooks/use-cases, types; no imports from `pages/`, Redux, axios or `apiUtil`
|
||||
2. **`app/`** — thin views (list, form, view as scoped) registered in `app/routes.tsx`
|
||||
3. **`api/api-paths.ts`** — feature endpoints centralized; responses via `handle-api-response.ts`
|
||||
4. **`infra/query-key/query-key.ts`** — feature query keys
|
||||
5. **Protected route** — navigation via `config/menu.ts` (when applicable to the wave)
|
||||
6. **Delete legacy** — `pages/<feature>/` folder (or equivalent files) removed after validation
|
||||
7. **No regression** — main flow tested manually or with Vitest (when available)
|
||||
|
||||
### Done criteria for special scope
|
||||
|
||||
| Scope | Additional criterion |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| **POC auth** | Login/logout with httpOnly cookies; `AuthProvider` replaces Redux + `tokenUtility` |
|
||||
| **POC work-orders list** | Paginated/filtered list with Ky + RQ; no form/view in this delivery |
|
||||
| **work-orders full** | Form + view (dispatch, checklist, signoff) functional |
|
||||
| **settings/dropdowns** | Problem/Trade/SubTrade dropdowns available for WO form |
|
||||
| **reports/documents** | Explicit decision: implement feature or remove menu links |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Architecture plan: [`ARCHITECTURE_PLAN.md`](ARCHITECTURE_PLAN.md)
|
||||
- IrisLoan conventions: `.cursor/rules/seahaven-admin.mdc`
|
||||
- Legacy routes: `src/App.js`
|
||||
- Legacy menu: `src/components/Sidebar.js`
|
||||
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Frontend maintainability conventions
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||
state. Use a ternary only when both branches render meaningful alternatives.
|
||||
|
||||
```tsx
|
||||
{
|
||||
error && <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
<Text variant="description" when={Boolean(description)}>
|
||||
{description}
|
||||
</Text>;
|
||||
```
|
||||
|
||||
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||
distinct from real either-or UI decisions.
|
||||
|
||||
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
|
||||
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
|
||||
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
|
||||
TypeScript checker for the type of the left operand and reports unless every union constituent is
|
||||
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
|
||||
fails closed: if type services are unavailable in a governed TSX file it reports rather than
|
||||
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
|
||||
same constraint at that component boundary.
|
||||
|
||||
Approved guard forms (choose by semantics, not as a blind codemod):
|
||||
|
||||
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
|
||||
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
|
||||
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
|
||||
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
|
||||
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
|
||||
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
|
||||
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
|
||||
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
|
||||
narrowing.
|
||||
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
|
||||
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
|
||||
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
|
||||
|
||||
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
|
||||
that needs a non-boolean operand must be rewritten into one of the approved forms above.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||
code, and asynchronous feedback. It owns:
|
||||
|
||||
- semantic HTML for each visual variant;
|
||||
- the display, body, and monospace font families;
|
||||
- default, muted, error, success, and warning tones;
|
||||
- accessible `alert` and `status` live regions for error and feedback text;
|
||||
- one-sided conditional text through `when`.
|
||||
|
||||
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
|
||||
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
|
||||
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
|
||||
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
|
||||
`aria-live` directly.
|
||||
|
||||
Live-region behavior:
|
||||
|
||||
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
|
||||
the polite region exists before its content changes and is announced reliably. Mounting an
|
||||
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
|
||||
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
|
||||
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
|
||||
|
||||
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
|
||||
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
|
||||
|
||||
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
|
||||
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
|
||||
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
|
||||
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
|
||||
static surface only. It cannot resolve dynamic or composed class values
|
||||
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
|
||||
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
Use the libraries already established in the application:
|
||||
|
||||
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||
- Zod owns form validation and inferred form value types.
|
||||
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||
invalidation, and retries where safe.
|
||||
|
||||
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||
benchmarks, a codemod plan, and an approved deprecation path.
|
||||
|
||||
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||
retry state. Do not place upload orchestration in a route-sized page component.
|
||||
|
||||
## Page state
|
||||
|
||||
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||
|
||||
- query loading, error, and empty states stay adjacent to the query result;
|
||||
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||
- route pages coordinate sections and navigation;
|
||||
- reusable sections own their interaction details;
|
||||
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||
|
||||
## Enforcement and rollout
|
||||
|
||||
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# SeaHaven UI - Complete Architecture Documentation
|
||||
# Sea Haven UI - Complete Architecture Documentation
|
||||
|
||||
> Modern React architecture with Redux Toolkit, React Query, and CSS Modules
|
||||
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
│ 5. BACKEND │
|
||||
│ │
|
||||
│ REST API │
|
||||
│ (SeaHaven Backend) │
|
||||
│ (Sea Haven Backend) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -112,16 +112,19 @@ src/
|
|||
### 1. State Management: Redux vs React Query
|
||||
|
||||
**Use Redux for:**
|
||||
|
||||
- ✅ User authentication (login/logout)
|
||||
- ✅ App-wide settings (theme, sidebar state)
|
||||
- ✅ Data that needs to be shared everywhere
|
||||
|
||||
**Use React Query for:**
|
||||
|
||||
- ✅ API data (lists, details)
|
||||
- ✅ Any server data
|
||||
- ✅ Automatic caching and refetching
|
||||
|
||||
**Use useState for:**
|
||||
|
||||
- ✅ Local component state
|
||||
- ✅ Form inputs
|
||||
- ✅ UI toggles (dropdowns, tabs)
|
||||
|
|
@ -129,18 +132,21 @@ src/
|
|||
### 2. CSS Modules (Built-in - No Dependencies!)
|
||||
|
||||
**Old Way** (Global CSS):
|
||||
|
||||
```javascript
|
||||
import './Component.css';
|
||||
<div className="container">
|
||||
```
|
||||
|
||||
**New Way** (CSS Modules):
|
||||
|
||||
```javascript
|
||||
import styles from './Component.module.css';
|
||||
<div className={styles.container}> // Automatically scoped!
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No naming conflicts
|
||||
- Better tree-shaking
|
||||
- No extra build tools needed
|
||||
|
|
@ -150,13 +156,13 @@ import styles from './Component.module.css';
|
|||
|
||||
```javascript
|
||||
// ❌ Bad
|
||||
queryKey: ['pmschedules']
|
||||
setTimeout(fn, 600)
|
||||
queryKey: ["pmschedules"];
|
||||
setTimeout(fn, 600);
|
||||
|
||||
// ✅ Good
|
||||
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from '../constants';
|
||||
queryKey: [PM_SCHEDULES_LIST]
|
||||
setTimeout(fn, DEBOUNCE_SEARCH)
|
||||
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from "../constants";
|
||||
queryKey: [PM_SCHEDULES_LIST];
|
||||
setTimeout(fn, DEBOUNCE_SEARCH);
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -164,27 +170,30 @@ setTimeout(fn, DEBOUNCE_SEARCH)
|
|||
## 💻 Code Examples
|
||||
|
||||
### Example 1: Loading Data with React Query
|
||||
|
||||
```javascript
|
||||
import { usePMSchedules } from '../hooks/api/usePMSchedules';
|
||||
import { useDebounce, DEBOUNCE_SEARCH } from '../constants';
|
||||
import { usePMSchedules } from "../hooks/api/usePMSchedules";
|
||||
import { useDebounce, DEBOUNCE_SEARCH } from "../constants";
|
||||
|
||||
function PMScheduleList() {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||
|
||||
const { data, isLoading, error } = usePMSchedules({
|
||||
search: debouncedSearch,
|
||||
page: 1
|
||||
|
||||
const { data, isLoading, error } = usePMSchedules({
|
||||
search: debouncedSearch,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<ul>
|
||||
{data.items.map(item => <li key={item.id}>{item.name}</li>)}
|
||||
{data.items.map((item) => (
|
||||
<li key={item.id}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -192,8 +201,9 @@ function PMScheduleList() {
|
|||
```
|
||||
|
||||
### Example 2: Using Redux Auth
|
||||
|
||||
```javascript
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
function MyComponent() {
|
||||
const { user, isAuthenticated, login, logout } = useAuth();
|
||||
|
|
@ -204,16 +214,16 @@ function MyComponent() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
Welcome {user.name}!
|
||||
<button onClick={logout}>Logout</button>
|
||||
Welcome {user.name}!<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Using Modals
|
||||
|
||||
```javascript
|
||||
import { useModal } from '../hooks/useModal';
|
||||
import { useModal } from "../hooks/useModal";
|
||||
|
||||
function MyComponent() {
|
||||
const deleteModal = useModal();
|
||||
|
|
@ -223,9 +233,7 @@ function MyComponent() {
|
|||
<button onClick={() => deleteModal.open(item)}>Delete</button>
|
||||
|
||||
{deleteModal.isOpen && (
|
||||
<Modal onClose={deleteModal.close}>
|
||||
Delete {deleteModal.data.name}?
|
||||
</Modal>
|
||||
<Modal onClose={deleteModal.close}>Delete {deleteModal.data.name}?</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -233,8 +241,9 @@ function MyComponent() {
|
|||
```
|
||||
|
||||
### Example 4: CSS Modules
|
||||
|
||||
```javascript
|
||||
import styles from './LoginPage.module.css';
|
||||
import styles from "./LoginPage.module.css";
|
||||
|
||||
function LoginPage() {
|
||||
return (
|
||||
|
|
@ -260,6 +269,7 @@ function LoginPage() {
|
|||
## 🔧 Common Patterns
|
||||
|
||||
### Pattern 1: Fetch and Display Data
|
||||
|
||||
```javascript
|
||||
const { data, isLoading, error } = useDataHook(params);
|
||||
|
||||
|
|
@ -270,6 +280,7 @@ return <Display data={data} />;
|
|||
```
|
||||
|
||||
### Pattern 2: Delete with Confirmation
|
||||
|
||||
```javascript
|
||||
const deleteMutation = useDeleteHook();
|
||||
const deleteModal = useModal();
|
||||
|
|
@ -286,6 +297,7 @@ const handleDelete = async () => {
|
|||
```
|
||||
|
||||
### Pattern 3: Search with Debounce
|
||||
|
||||
```javascript
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||
|
|
@ -300,42 +312,48 @@ const { data } = useDataHook({ search: debouncedSearch });
|
|||
## 📚 Constants Reference
|
||||
|
||||
### API Constants
|
||||
|
||||
```javascript
|
||||
API_URL // Backend URL
|
||||
API_ERROR_MESSAGE // Default error message
|
||||
API_SUCCESS_MESSAGE // Success message
|
||||
API_URL; // Backend URL
|
||||
API_ERROR_MESSAGE; // Default error message
|
||||
API_SUCCESS_MESSAGE; // Success message
|
||||
```
|
||||
|
||||
### Timing Constants
|
||||
|
||||
```javascript
|
||||
DEBOUNCE_SEARCH // 600ms
|
||||
CACHE_TIME_MEDIUM // 5 minutes
|
||||
STALE_TIME_MEDIUM // 5 minutes
|
||||
DEBOUNCE_SEARCH; // 600ms
|
||||
CACHE_TIME_MEDIUM; // 5 minutes
|
||||
STALE_TIME_MEDIUM; // 5 minutes
|
||||
```
|
||||
|
||||
### View Modes
|
||||
|
||||
```javascript
|
||||
VIEW_MODE_LIST // 'list'
|
||||
VIEW_MODE_CARD // 'card'
|
||||
VIEW_MODE_LIST; // 'list'
|
||||
VIEW_MODE_CARD; // 'card'
|
||||
```
|
||||
|
||||
### Status Types
|
||||
|
||||
```javascript
|
||||
STATUS_OPEN // 'Open'
|
||||
STATUS_COMPLETED // 'Completed'
|
||||
STATUS_OPEN; // 'Open'
|
||||
STATUS_COMPLETED; // 'Completed'
|
||||
```
|
||||
|
||||
### Query Keys
|
||||
|
||||
```javascript
|
||||
PM_SCHEDULES_LIST // 'pmSchedulesList'
|
||||
WORK_ORDERS_LIST // 'workOrdersList'
|
||||
EMPLOYEES_DROPDOWN // 'employeesDropdown'
|
||||
PM_SCHEDULES_LIST; // 'pmSchedulesList'
|
||||
WORK_ORDERS_LIST; // 'workOrdersList'
|
||||
EMPLOYEES_DROPDOWN; // 'employeesDropdown'
|
||||
```
|
||||
|
||||
### Storage Keys
|
||||
|
||||
```javascript
|
||||
STORAGE_KEY_TOKEN // 'token'
|
||||
STORAGE_KEY_THEME // 'theme'
|
||||
STORAGE_KEY_TOKEN; // 'token'
|
||||
STORAGE_KEY_THEME; // 'theme'
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -343,16 +361,19 @@ STORAGE_KEY_THEME // 'theme'
|
|||
## 🚀 Getting Started
|
||||
|
||||
### 1. Run Development Server
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### 2. Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Deploy to S3
|
||||
|
||||
```bash
|
||||
aws s3 sync build/ s3://shoc-ui-app --acl public-read
|
||||
```
|
||||
|
|
@ -364,13 +385,16 @@ aws s3 sync build/ s3://shoc-ui-app --acl public-read
|
|||
### Converting a Page to New Architecture
|
||||
|
||||
**Step 1: Use React Query Hook**
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchData().then(setData).finally(() => setLoading(false));
|
||||
fetchData()
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// New
|
||||
|
|
@ -378,6 +402,7 @@ const { data, isLoading } = usePMSchedules({ page: 1 });
|
|||
```
|
||||
|
||||
**Step 2: Use Constants**
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -387,11 +412,12 @@ useEffect(() => {
|
|||
}, [search]);
|
||||
|
||||
// New
|
||||
import { DEBOUNCE_SEARCH } from '../constants';
|
||||
import { DEBOUNCE_SEARCH } from "../constants";
|
||||
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||
```
|
||||
|
||||
**Step 3: Convert CSS to Modules**
|
||||
|
||||
```javascript
|
||||
// 1. Rename: Component.css → Component.module.css
|
||||
// 2. Update import: import styles from './Component.module.css';
|
||||
|
|
@ -403,14 +429,17 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
## ✅ What's Been Refactored
|
||||
|
||||
### Pages
|
||||
|
||||
- ✅ **LoginPage** - Uses Redux for auth
|
||||
- ✅ **PM Schedules List** - Uses React Query, constants, CSS Modules
|
||||
|
||||
### Components
|
||||
|
||||
- ✅ **Topbar** - Uses Redux for user state
|
||||
- ✅ **DeleteModal** - Reusable component
|
||||
|
||||
### Hooks
|
||||
|
||||
- ✅ **useAuth** - Redux integration for login/logout
|
||||
- ✅ **useModal** - Reusable modal state
|
||||
- ✅ **useDebounce** - Search debouncing
|
||||
|
|
@ -433,6 +462,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
## 📖 Quick Reference
|
||||
|
||||
### File to Check for Examples
|
||||
|
||||
- `src/pages/PmSchedule/list/List.js` - Fully refactored list page
|
||||
- `src/pages/auth/LoginPage.js` - Redux auth + CSS Modules
|
||||
- `src/hooks/api/usePMSchedules.js` - React Query hook pattern
|
||||
|
|
@ -463,15 +493,19 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
## 🛠️ Troubleshooting
|
||||
|
||||
### Issue: Constants not found
|
||||
|
||||
**Solution:** Make sure you're importing from `'../constants'` (folder) not `'../constants.js'` (old file)
|
||||
|
||||
### Issue: CSS not scoped
|
||||
|
||||
**Solution:** File must be named `.module.css` and imported as `import styles from`
|
||||
|
||||
### Issue: Redux state not updating
|
||||
|
||||
**Solution:** Make sure you're using the hooks (`useAuth`, `useSelector`) not direct access
|
||||
|
||||
### Issue: React Query not caching
|
||||
|
||||
**Solution:** Check that query keys use constants and are consistent
|
||||
|
||||
---
|
||||
|
|
@ -479,6 +513,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
## 🎉 Summary
|
||||
|
||||
### What We Built:
|
||||
|
||||
- ✅ Modern React architecture
|
||||
- ✅ Redux Toolkit for global state
|
||||
- ✅ React Query for server state
|
||||
|
|
@ -487,6 +522,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
- ✅ Custom hooks for reusable logic
|
||||
|
||||
### Benefits:
|
||||
|
||||
- 📉 70% less boilerplate code
|
||||
- 🚀 Automatic caching and refetching
|
||||
- 🎨 No CSS naming conflicts
|
||||
|
|
@ -495,6 +531,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
- ⚡ Better performance
|
||||
|
||||
### Next Steps:
|
||||
|
||||
1. Refactor remaining pages using PM Schedules as template
|
||||
2. Convert more CSS to CSS Modules
|
||||
3. Add more API hooks as needed
|
||||
|
|
@ -502,7 +539,6 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
|||
|
||||
---
|
||||
|
||||
**Welcome to the modern SeaHaven UI! 🎊**
|
||||
|
||||
*Last updated: 2026*
|
||||
**Welcome to the modern Sea Haven UI! 🎊**
|
||||
|
||||
_Last updated: 2026_
|
||||
91
docs/adr/0001-query-broadcast-client.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 0001. Cross-tab QueryClient sync via `@tanstack/query-broadcast-client-experimental`
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA is frequently used with multiple browser tabs open
|
||||
against the same work-order board (e.g. a dispatcher triaging the board in one
|
||||
tab while editing a work order in another). Each tab owns its own TanStack
|
||||
Query `QueryClient` cache, so a mutation performed in one tab (status change,
|
||||
dispatch creation, comment, vendor patch, etc.) does not invalidate or update
|
||||
the cache in sibling tabs. Users were seeing stale board/detail data until a
|
||||
manual refresh or the next background refetch.
|
||||
|
||||
We need a way to keep `QueryClient` caches roughly in sync across tabs of the
|
||||
same origin, without introducing a new state-management layer (Redux is
|
||||
disallowed by this repo's conventions) or a server-push mechanism.
|
||||
|
||||
## Decision
|
||||
|
||||
Use TanStack's own experimental broadcast client,
|
||||
[`@tanstack/query-broadcast-client-experimental`](https://tanstack.com/query),
|
||||
wired up in a single dedicated setup module,
|
||||
`src/lib/query/setup-query-broadcast.ts`. The module:
|
||||
|
||||
- Exposes `startQueryBroadcast(queryClient, userId)` and
|
||||
`stopQueryBroadcast(queryClient)`.
|
||||
- Keys `broadcastQueryClient` to an **account-scoped** channel
|
||||
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
|
||||
on the same origin do not share cache traffic.
|
||||
- Disposes the prior subscription (the unsubscribe returned by
|
||||
`broadcastQueryClient`) and clears the `QueryClient` when the authenticated
|
||||
user changes or the session ends.
|
||||
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
|
||||
(`import.meta.env.MODE === "test"`).
|
||||
- Is started after authentication (`AuthProvider` session restore + login
|
||||
success) and stopped on logout and HTTP 401 session clear — not at
|
||||
`QueryClient` module load.
|
||||
|
||||
This piggybacks on the query cache we already have (no parallel store), uses
|
||||
the library that owns the `QueryClient` we already depend on, and keeps the
|
||||
integration isolated to auth/session boundaries.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Custom `BroadcastChannel` + manual `queryClient.invalidateQueries` calls**
|
||||
— full control over payloads, but requires hand-rolling
|
||||
serialization/versioning of query keys and mutation results, and keeping
|
||||
every future mutation hook wired to broadcast. More code to own and more
|
||||
surface area for subtle cache-desync bugs.
|
||||
- **Constant channel from module load** — simplest wiring, but shares one
|
||||
channel across all sessions on the origin; after logout/login or multi-account
|
||||
use, cached work-order/vendor data can leak into the next session. Rejected.
|
||||
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
|
||||
unresolved; users would need to manually refresh or wait for
|
||||
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
||||
for a live dispatch board.
|
||||
- **Full WebSocket-based real-time sync** — solves both cross-tab and
|
||||
cross-user staleness, but is a materially larger investment (server-side
|
||||
push infra, connection lifecycle, auth over the socket) that is out of scope
|
||||
for the current admin SPA and not justified by the actual problem (same
|
||||
browser, same user, same origin).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive**: sibling tabs for the same authenticated account reflect
|
||||
mutations (status changes, dispatch actions, comments, patches) without a
|
||||
manual refresh; logout/401 tear down the broadcaster and clear sensitive
|
||||
cache so the next account cannot inherit prior data.
|
||||
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
|
||||
the TanStack ecosystem; its API may change or be deprecated between minor
|
||||
versions. `@tanstack/query-broadcast-client-experimental`,
|
||||
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
||||
the **exact same version** in `package.json` (no `^` range). All three
|
||||
resolve to the identical `@tanstack/query-core` version at that pin, which
|
||||
keeps the `QueryClient` type used by the broadcast helpers structurally
|
||||
identical to the one constructed in `query-client.ts` — a caret range lets
|
||||
npm resolve the broadcast client and React Query against two different
|
||||
`query-core` versions independently, which breaks `QueryClient` type
|
||||
identity (TS2322) even though both packages build fine in isolation. Bump
|
||||
all three together and re-evaluate on every TanStack Query upgrade.
|
||||
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
||||
origin; it does not sync across different users/sessions or devices. Account
|
||||
scoping further limits sync to tabs of the same `userId`.
|
||||
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
||||
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
|
||||
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
|
||||
`api.ts`. No other code depends on it, so rollback is isolated with no data
|
||||
migration.
|
||||
94
docs/adr/0002-sentry-observability.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# 0002. Browser error and transaction telemetry with Sentry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA has no centralized browser error reporting or distributed
|
||||
transaction tracing. Operational failures therefore have to be reconstructed
|
||||
from user reports and isolated backend logs. We need browser navigation and API
|
||||
request transactions to join the backend trace while preserving the existing UI,
|
||||
routing, authentication, and server-state behavior. Every emitted transaction
|
||||
must also identify its exact build, trace, transaction, operation, function, and
|
||||
opaque actor without sending credentials, request bodies, query values, or
|
||||
personal data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
|
||||
router is created, and wrap `createBrowserRouter` with Sentry's current React
|
||||
Router compatibility API. Configure the browser tracing integration with a 100%
|
||||
transaction sample rate, and propagate trace headers only to the SPA origin and
|
||||
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
||||
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
|
||||
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
|
||||
and recoverable component errors as Sentry events.
|
||||
|
||||
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
|
||||
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
|
||||
They use normalized route templates and attach `code.function`, `operation.type`,
|
||||
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
|
||||
receive the same searchable trace identifiers in `beforeSendTransaction`.
|
||||
Authenticated sessions set only the application's opaque numeric/string user ID;
|
||||
logout and session expiry clear it.
|
||||
|
||||
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
|
||||
browser configuration because every `VITE_` value is embedded in the built
|
||||
JavaScript. `.env.production` commits the public DSN of the real
|
||||
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
|
||||
or blank DSN in any other build leaves telemetry inactive without preventing
|
||||
the application from starting. Default personally identifiable information
|
||||
collection remains disabled.
|
||||
|
||||
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
|
||||
non-blank, falling back to the Vite build mode otherwise. The committed
|
||||
`.env.production` labels dev deploys `development`, the staging deploy job
|
||||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
|
||||
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
|
||||
resolve that identity. Vite creates hidden source maps, and the dev/staging
|
||||
deployment workflows upload them to the private `shoc-frontend` Sentry project
|
||||
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
|
||||
excludes `*.map`; the upload credential is never available to the browser build.
|
||||
|
||||
Before-send processors reduce request data to normalized URL plus HTTP method,
|
||||
reduce users to opaque ID only, attach native trace/transaction identifiers as
|
||||
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
|
||||
URL metadata. Root and child span attributes use the same allowlist, retaining
|
||||
function, operation, actor type, normalized route, method/status, service host,
|
||||
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
|
||||
cookies, query strings/values, free-form breadcrumb messages, usernames, email
|
||||
addresses, IP addresses, tokens, and filenames are not intentionally sent.
|
||||
`sendDefaultPii` remains disabled.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Manual error and timing calls** — rejected because they would miss route
|
||||
transitions and distributed request context, and would require every feature
|
||||
to maintain its own instrumentation.
|
||||
- **Propagate tracing headers to every request** — rejected because third-party
|
||||
requests must not receive SeaHaven trace metadata and broad propagation can
|
||||
introduce cross-origin request failures.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser navigations and API requests are recorded as connected transactions
|
||||
when the deployment supplies a valid DSN.
|
||||
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
|
||||
deploys, `staging` on the staging job), falling back to the Vite build mode
|
||||
when unset.
|
||||
- Every transaction is sampled. This meets the current requirement but increases
|
||||
event volume and should be reviewed against Sentry quota and retention after
|
||||
real traffic is measured.
|
||||
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
|
||||
`baggage` request headers; the current API CORS policy allows request headers.
|
||||
- Deployed stack traces resolve against the exact commit's private source maps
|
||||
once the scoped upload secret is present and the deployment workflow runs.
|
||||
- Rollback is isolated: remove `@sentry/react`, the initialization module and
|
||||
import, restore the unwrapped router factory, and remove the Sentry env entries.
|
||||
35
docs/work-orders/addon-indicator.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Add-On indicator (SH-184)
|
||||
|
||||
Product rule superseding the SH-126 “frozen at create” FE assumption.
|
||||
|
||||
## Authority
|
||||
|
||||
| Concern | Owner |
|
||||
| ------------------------------------- | -------- |
|
||||
| Cutoff math & week-boundary recalc | Backend |
|
||||
| Clear `isAddOn` when schedule cleared | Backend |
|
||||
| Audit of every recalculation | Backend |
|
||||
| Display / board overlay | Frontend |
|
||||
|
||||
The frontend **must not** treat `isAddOn` as frozen after create. Create may
|
||||
send a **hint** (`resolveIsAddOnForCreate` / cutoff preview or unscheduled
|
||||
manual override). After any schedule patch or clear, FE consumes the
|
||||
authoritative `isAddOn` returned by the board PATCH (and subsequent board
|
||||
fetch).
|
||||
|
||||
## FE contract
|
||||
|
||||
```
|
||||
Board row / PATCH response: isAddOn: boolean
|
||||
Create (hint): isAddOn = cutoff preview | manual override
|
||||
Schedule day/week patch: FE applies returned row.isAddOn (ON↔OFF)
|
||||
Schedule cleared: FE expects isAddOn === false from BE;
|
||||
optimistic clear sets isAddOn: false locally
|
||||
Legacy: workOrderType=AddOn(7) without isAddOn ⇒ isAddOn=true
|
||||
```
|
||||
|
||||
## Dependency
|
||||
|
||||
Full backend recalculation + audit may land in a separate BE PR. Until then,
|
||||
FE still maps and overlays whatever `isAddOn` the API returns so the contract
|
||||
is correct once BE ships.
|
||||
210
docs/work-orders/board-search-api.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# Work Orders Board & Search API
|
||||
|
||||
Frontend integration contract for weekly board and advanced search filters.
|
||||
|
||||
## Base
|
||||
|
||||
| Item | Valor |
|
||||
| ------------ | ------------------------------------------- |
|
||||
| Prefixo | `/api/workorders` (alias: `/api/WorkOrder`) |
|
||||
| Auth | `Authorization: Bearer {token}` (JWT) |
|
||||
| Content-Type | Não necessário (GET) |
|
||||
|
||||
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
||||
|
||||
## Contrato (board semanal vs search)
|
||||
|
||||
| Superfície | Endpoint | O que volta |
|
||||
| ------------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Week + Day (AAP) | `GET /board?weekStart&weekEnd` | Só WOs com data (ou `ScheduleWeekOnly` + `TargetWeek`) **nessa semana**. `unscheduled: []`. Sem `page`/`pageSize`. |
|
||||
| Contagens do board | `counts.total` / `counts.returned` | Scheduled da semana (antes/depois do search da barra). Nunca `0` se há rows. |
|
||||
| Lista flat | `GET /board/search` | Envelope paginado. `page` 0-based. `pageSize` default 100, **max 200**. |
|
||||
| Fila sem data / sem dispatcher | `GET /board/search` | Filtros (`dispatchers=__unassigned__`, statuses, janela Custom). **Não** entra no `/board`. |
|
||||
|
||||
O AAP não tem pin Unassigned na tabela. Dated sem assignee entram no dia. Undated só em Advanced Filters / filtro Dispatcher.
|
||||
|
||||
## Quando usar qual endpoint
|
||||
|
||||
| Modo FE | Endpoint | Quando |
|
||||
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
|
||||
| Barra principal (`advApplied === null`) | `GET /board` | Semana agendada + dispatchers + tipo + search |
|
||||
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
|
||||
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
|
||||
|
||||
**Não misture parâmetros:** `weekStart`/`weekEnd` só no `/board`; `datePreset` só no `/search`.
|
||||
|
||||
## Query builders (FE)
|
||||
|
||||
- [`board-query-params.ts`](../../src/domain/work-orders/utils/board-query-params.ts) — `toBoardQueryParams`, `toBoardSearchQueryParams`
|
||||
- Arrays na query: repetição simples (`types=2&types=6`), via `toUrlSearchParams` em `work-orders-api.ts`
|
||||
|
||||
## Barra → `GET /board`
|
||||
|
||||
| FE state | API param | Regra |
|
||||
| --------------- | ---------------------- | ----------------------------------------------------------- |
|
||||
| `search` | `search` | Enviar só se `length >= 2`; com 1 char, filtrar client-side |
|
||||
| `weekMonday` | `weekStart` | ISO `YYYY-MM-DD` (segunda) |
|
||||
| — | `weekEnd` | `weekStart + 4` (sexta) |
|
||||
| `dispatcherIds` | `dispatchers` | `__unassigned` → `__unassigned__` |
|
||||
| "My WOs" | `dispatchers={userId}` | Equivalente a `myWorkOrders=true` |
|
||||
| `typeFilter` | `types` / `overdue` | Tipos reais em `types`; past-due via `overdue=true` (OR) |
|
||||
|
||||
## Advanced → `GET /board/search`
|
||||
|
||||
| FE state | API param | Regra |
|
||||
| ---------------- | --------------------- | ------------------------------------------- |
|
||||
| `page` | `page` | **0-based** (primeira página = `0`) |
|
||||
| `pageSize` | `pageSize` | Default `100`, max `200` |
|
||||
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
|
||||
| `sortDir` | `sortDir` | `asc` \| `desc` |
|
||||
| `search` | `search` | `>= 2` chars |
|
||||
| `dateRange` | `datePreset` | PascalCase (ver abaixo) |
|
||||
| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` |
|
||||
| `sites` (IDs UI) | `sites` | **Site codes**, não location ID |
|
||||
| `types` | `types` | Integers de `WorkOrderType` reais |
|
||||
| overdue (UI) | `overdue` | `true` = past-due (`isPastDue`); ver abaixo |
|
||||
| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` |
|
||||
| `statuses` | `statuses` | Integers 1–10 |
|
||||
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
|
||||
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
|
||||
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
|
||||
| `severities` | `severities` | Integers `1`–`5` (Emergency/Reactive) |
|
||||
| `rescheduled` | `rescheduled` | `true` = reschedule count ≥ 2 |
|
||||
| `carriedOver` | `carriedOver` | `true` = carried-over count ≥ 2 |
|
||||
| `addOn` | `addOn` | `true` = `isAddOn` indicator |
|
||||
| `flagColors` | `flagColors` | Multi `#RRGGBB` (board flag palette) |
|
||||
| `internalOnly` | `internalOnly` | `true` = WO# starts with `SH` |
|
||||
| `hasUplift` | `hasUplift` | `true` = WO has standing uplift |
|
||||
| `upliftStatuses` | `upliftStatuses` | With `hasUplift`; see below |
|
||||
|
||||
### `severities`
|
||||
|
||||
Multi-select SEV 1–5. Applies to Emergency/Reactive work orders with a transcribed APM severity.
|
||||
|
||||
### Indicators (`rescheduled`, `carriedOver`, `addOn`)
|
||||
|
||||
Boolean flags matching board badge thresholds (counters ≥ 2 for reschedule/carried-over). `addOn` filters the `isAddOn` indicator (not a WO type).
|
||||
|
||||
### `flagColors`
|
||||
|
||||
Repeated `#RRGGBB` values from the fixed board flag palette (`WorkOrderFlagColors`).
|
||||
|
||||
### `internalOnly`
|
||||
|
||||
When `true`, only work orders whose number starts with `SH` (internal placeholder).
|
||||
|
||||
### Uplift filters
|
||||
|
||||
| FE state | API param | Regra |
|
||||
| ---------------- | ---------------- | ------------------------------------------------ |
|
||||
| `hasUplift` | `hasUplift` | `true` = WO has non-cancelled/non-revoked uplift |
|
||||
| `upliftStatuses` | `upliftStatuses` | Optional refinement when `hasUplift=true` |
|
||||
|
||||
Allowed `upliftStatuses`: `pending`, `approved`, `auto_approved`, `rejected` (excludes cancelled/revoked).
|
||||
|
||||
Exemplo: pending uplifts only — `hasUplift=true&upliftStatuses=pending`.
|
||||
|
||||
## Backend producer (`shoc-backend` `dev`)
|
||||
|
||||
These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/dev` ([#45](https://github.com/Sea-Haven-Industries/shoc-backend/pull/45), [#65](https://github.com/Sea-Haven-Industries/shoc-backend/pull/65)). There is no frontend deployment fence.
|
||||
|
||||
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
|
||||
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
|
||||
- Pagination matches this contract: `page` is 0-based; `pageSize` default is 100, max is 200.
|
||||
- `GET /board` returns only scheduled-in-week rows. `unscheduled` is always `[]`. Undated / global Unassigned live on `GET /board/search`.
|
||||
- `counts.total` / `counts.returned` are scheduled-in-week (before/after the bar search).
|
||||
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
|
||||
|
||||
### `datePreset`
|
||||
|
||||
| FE key | API value |
|
||||
| --------------- | ------------- |
|
||||
| `this-week` | `ThisWeek` |
|
||||
| `last-week` | `LastWeek` |
|
||||
| `this-month` | `ThisMonth` |
|
||||
| `last-3-months` | `Last3Months` |
|
||||
| `next-week` | `NextWeek` |
|
||||
| `next-month` | `NextMonth` |
|
||||
| `custom` | `Custom` |
|
||||
|
||||
### `types` — WorkOrderType
|
||||
|
||||
Somente tipos reais de work order. **Não** use sentinel `99` para overdue.
|
||||
|
||||
| Label FE | API |
|
||||
| --------- | -------------------------------------------- |
|
||||
| PM | `2` |
|
||||
| Emergency | `3` |
|
||||
| Reactive | `6` |
|
||||
| Add-On | `7` |
|
||||
| Other | tipo real do enum (não é filtro de past-due) |
|
||||
|
||||
### `overdue` — past-due
|
||||
|
||||
| FE / UI | API param | Regra |
|
||||
| ------- | -------------- | ----------------------------------------- |
|
||||
| Overdue | `overdue=true` | Filtra work orders past-due (`isPastDue`) |
|
||||
|
||||
`types` e `overdue` combinam com **OR**: uma WO entra no resultado se corresponder a algum `types` **ou** estiver overdue (quando `overdue=true`).
|
||||
|
||||
Exemplos:
|
||||
|
||||
- Só PM e Emergency: `types=2&types=3`
|
||||
- Só past-due: `overdue=true`
|
||||
- PM **ou** past-due: `types=2&overdue=true`
|
||||
|
||||
### `statuses` — LifecycleStatus
|
||||
|
||||
Incomplete=1, Pending=2, Scheduled=3, En Route=4, On Site=5, In Progress=6, Completed=7, Rescheduled=8, Canceled=9, Pending Quote=10.
|
||||
|
||||
### `docStatuses`
|
||||
|
||||
Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3.
|
||||
|
||||
## Respostas
|
||||
|
||||
### `/board`
|
||||
|
||||
```json
|
||||
{
|
||||
"weekStart": "2026-07-14",
|
||||
"weekEnd": "2026-07-18",
|
||||
"counts": { "total": 42, "returned": 10 },
|
||||
"scheduled": [],
|
||||
"unscheduled": []
|
||||
}
|
||||
```
|
||||
|
||||
### `/board/search`
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"totalCount": 150,
|
||||
"page": 0,
|
||||
"pageSize": 100,
|
||||
"totalPages": 2,
|
||||
"hasPrevious": false,
|
||||
"hasNext": true
|
||||
}
|
||||
```
|
||||
|
||||
## Erros HTTP
|
||||
|
||||
| Situação | Status |
|
||||
| --------------------------------------------------- | ------ |
|
||||
| `weekEnd < weekStart` ou janela > 7 dias | 400 |
|
||||
| `datePreset=Custom` sem datas / `dateTo < dateFrom` | 400 |
|
||||
| `sortBy` inválido | 400 |
|
||||
| Sem token / expirado | 401 |
|
||||
|
||||
Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`.
|
||||
|
||||
## Add-On indicator
|
||||
|
||||
See [addon-indicator.md](./addon-indicator.md) (SH-184): backend recalculates
|
||||
`isAddOn` on schedule move/clear with audit; FE only reflects the API value.
|
||||
|
||||
## Validação UI
|
||||
|
||||
O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário.
|
||||
30
docs/work-orders/pending-be-contract.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Work-order `additionalContacts` contract (SH-218)
|
||||
|
||||
Authoritative frontend contract for additional POC 2+ contacts. Implemented on
|
||||
backend PR [shoc-backend#66](https://github.com/Sea-Haven-Industries/shoc-backend/pull/66).
|
||||
|
||||
## Shape
|
||||
|
||||
```ts
|
||||
{ name: string; phone: string; notes?: string }
|
||||
```
|
||||
|
||||
- Primary POC remains `pocName` / `pocPhone` / `pocNotes`.
|
||||
- `additionalContacts` holds ordered POC 2+ only.
|
||||
- Notes are optional. An empty list (or omitted field) is valid.
|
||||
- Retained entries require **both** `name` and `phone`. Fully blank placeholder
|
||||
rows are dropped and must not fail create. Name-only or phone-only entries are
|
||||
rejected on POST create and PATCH.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Operation | Where |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| POST create | `additionalContacts` array on the board create body |
|
||||
| GET board / search | `additionalContacts` on each board row |
|
||||
| GET detail | `info.additionalContacts` (same ordered list as board) |
|
||||
| PATCH | `field: "additionalContacts"`, `value` is a JSON array string |
|
||||
|
||||
Until #66 is merged to the deployed API, a missing field is still mapped as `[]`
|
||||
on the frontend so the UI does not throw. After merge, create → reload →
|
||||
board/slide-over must round-trip the ordered list through GET detail `info`.
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 120 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
vendored
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
vendored
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
vendored
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
vendored
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 189 KiB |
104
e2e/auth/login.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "test-jwt-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "user@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Test User",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function mockDashboardStats(page: Page) {
|
||||
await page.route("**/api/Dashboard/Stats", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
openWorkOrders: 0,
|
||||
pendingApprovals: 0,
|
||||
scheduledPMs: 0,
|
||||
activeAssets: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Login page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem("auth");
|
||||
});
|
||||
await mockDashboardStats(page);
|
||||
});
|
||||
|
||||
test("loads login page with form fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
|
||||
await expect(page.locator("#login-email")).toBeVisible();
|
||||
await expect(page.locator("#login-password")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("disables submit until email and password are valid", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
const submitButton = page.getByRole("button", { name: "Sign In" });
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await page.locator("#login-email").fill("not-an-email");
|
||||
await page.locator("#login-password").fill("secret123");
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("shows error message for invalid credentials", async ({ page }) => {
|
||||
await page.route("**/api/Authentication/Login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Invalid credentials" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await page.locator("#login-password").fill("wrong-password");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByText("Invalid email or password. Please try again.")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("logs in successfully and navigates to dashboard", async ({ page }) => {
|
||||
await page.route("**/api/Authentication/Login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: TEST_USER }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await page.locator("#login-password").fill("secret123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible();
|
||||
|
||||
const storedAuth = await page.evaluate(() => localStorage.getItem("auth"));
|
||||
expect(storedAuth).toContain(TEST_USER.token);
|
||||
expect(storedAuth).toContain(TEST_USER.fullname);
|
||||
});
|
||||
});
|
||||
256
e2e/dashboard/dashboard.visual.spec.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
function testUser(userRoles: string) {
|
||||
return {
|
||||
token: "dashboard-visual-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles,
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Dashboard Visual User",
|
||||
id: "1",
|
||||
};
|
||||
}
|
||||
|
||||
const DISPATCHERS = [
|
||||
{ id: "1", name: "Pat Dispatcher" },
|
||||
{ id: "2", name: "Riley Chen" },
|
||||
{ id: "3", name: "Sam Okafor" },
|
||||
];
|
||||
|
||||
const STATS = {
|
||||
total: 128,
|
||||
open: 52,
|
||||
notDispatched: 8,
|
||||
completed: 64,
|
||||
dueCount: 40,
|
||||
completedDueCount: 30,
|
||||
completionRate: 82,
|
||||
averageResolutionDays: 3.4,
|
||||
scheduledTomorrow: 6,
|
||||
pendingUplifts: 4,
|
||||
avetaPending: 3,
|
||||
breakdown: [
|
||||
{ status: "PM", count: 24 },
|
||||
{ status: "Emergency", count: 6 },
|
||||
{ status: "Reactive", count: 12 },
|
||||
{ status: "Overdue", count: 9 },
|
||||
],
|
||||
statusDistribution: [
|
||||
{ status: "Scheduled", count: 30 },
|
||||
{ status: "In Progress", count: 18 },
|
||||
{ status: "Completed", count: 64 },
|
||||
{ status: "Pending", count: 16 },
|
||||
],
|
||||
};
|
||||
|
||||
const WORKLOAD = {
|
||||
items: [
|
||||
{
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
openWorkOrders: 12,
|
||||
totalWorkOrders: 20,
|
||||
},
|
||||
{ dispatcherId: "2", dispatcherName: "Riley Chen", openWorkOrders: 7, totalWorkOrders: 15 },
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalDispatchers: 2,
|
||||
};
|
||||
|
||||
const PERFORMANCE = {
|
||||
items: [
|
||||
{
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
completionRate: 94,
|
||||
averageResolutionDays: 2.5,
|
||||
assignedCount: 20,
|
||||
completedCount: 18,
|
||||
},
|
||||
{
|
||||
dispatcherId: "2",
|
||||
dispatcherName: "Riley Chen",
|
||||
completionRate: 78,
|
||||
averageResolutionDays: 3.1,
|
||||
assignedCount: 15,
|
||||
completedCount: 11,
|
||||
},
|
||||
{
|
||||
dispatcherId: "3",
|
||||
dispatcherName: "Sam Okafor",
|
||||
completionRate: 61,
|
||||
averageResolutionDays: 4.4,
|
||||
assignedCount: 9,
|
||||
completedCount: 5,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalDispatchers: 3,
|
||||
};
|
||||
|
||||
const REGIONS = {
|
||||
items: [
|
||||
{ region: "East", workOrderCount: 44 },
|
||||
{ region: "Central", workOrderCount: 31 },
|
||||
{ region: "West", workOrderCount: 22 },
|
||||
{ region: "California", workOrderCount: 18 },
|
||||
{ region: "Unmapped/Other", workOrderCount: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
const VENDOR_INSIGHTS = {
|
||||
items: [
|
||||
{
|
||||
vendorId: "101",
|
||||
vendorName: "Gateway Plumbing",
|
||||
completionRate: 88,
|
||||
rescheduleRate: 12,
|
||||
averageResolutionDays: 2.9,
|
||||
totalJobs: 40,
|
||||
},
|
||||
{
|
||||
vendorId: "102",
|
||||
vendorName: "Summit HVAC",
|
||||
completionRate: 76,
|
||||
rescheduleRate: 20,
|
||||
averageResolutionDays: 3.6,
|
||||
totalJobs: 28,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TREND = {
|
||||
granularity: "week",
|
||||
buckets: [
|
||||
{
|
||||
date: "2026-07-27",
|
||||
label: "Jul 27",
|
||||
total: 18,
|
||||
open: 4,
|
||||
completed: 12,
|
||||
canceled: 1,
|
||||
overdue: 1,
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
date: "2026-08-03",
|
||||
label: "Aug 3",
|
||||
total: 22,
|
||||
open: 6,
|
||||
completed: 13,
|
||||
canceled: 1,
|
||||
overdue: 2,
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
date: "2026-08-10",
|
||||
label: "Aug 10",
|
||||
total: 26,
|
||||
open: 8,
|
||||
completed: 15,
|
||||
canceled: 0,
|
||||
overdue: 3,
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
date: "2026-08-17",
|
||||
label: "Aug 17",
|
||||
total: 20,
|
||||
open: 9,
|
||||
completed: 9,
|
||||
canceled: 1,
|
||||
overdue: 1,
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockDashboardApi(page: Page, userRoles: string) {
|
||||
await page.clock.setFixedTime(new Date(FROZEN_NOW));
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
testUser(userRoles),
|
||||
);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
|
||||
// Only stub real backend calls; let Vite dev module requests (e.g.
|
||||
// /src/domain/auth/api/*.ts) through untouched.
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
|
||||
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
|
||||
if (pathname.includes("/Dashboard/Performance"))
|
||||
return fulfillJson(route, { data: PERFORMANCE });
|
||||
if (pathname.includes("/Dashboard/Regions")) return fulfillJson(route, { data: REGIONS });
|
||||
if (pathname.includes("/Dashboard/VendorInsights")) {
|
||||
return fulfillJson(route, { data: VENDOR_INSIGHTS });
|
||||
}
|
||||
if (pathname.includes("/Dashboard/Trend")) return fulfillJson(route, { data: TREND });
|
||||
if (pathname.includes("/workorders/lookups/dispatchers")) {
|
||||
return fulfillJson(route, { data: DISPATCHERS });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
async function openDashboard(page: Page, userRoles: string) {
|
||||
await mockDashboardApi(page, userRoles);
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
.MuiDrawer-paper { transform: none !important; }
|
||||
.Toastify__toast-container { display: none !important; }
|
||||
.tsqd-parent-container { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStableScreenshot(page: Page, name: string) {
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await expect(page).toHaveScreenshot(name, { fullPage: true });
|
||||
}
|
||||
|
||||
test.describe("Dashboard deterministic pixel regression", () => {
|
||||
test("admin dashboard", async ({ page }) => {
|
||||
await openDashboard(page, "Admin");
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
await expect(page.getByText("Status Distribution")).toBeVisible();
|
||||
await expect(page.getByText("Dispatcher Performance")).toBeVisible();
|
||||
// The scope picker is available to Admin/Scheduler (SH-336).
|
||||
await expect(page.getByRole("button", { name: /All dispatchers/ })).toBeVisible();
|
||||
await expectStableScreenshot(page, "dashboard-admin.png");
|
||||
});
|
||||
|
||||
test("dispatcher scoped dashboard", async ({ page }) => {
|
||||
await openDashboard(page, "Dispatcher");
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
// A Dispatcher gets the static "My WOs" label and no dispatcher tables (SH-336).
|
||||
await expect(page.getByText("My WOs")).toBeVisible();
|
||||
await expect(page.getByText("Dispatcher Performance")).toHaveCount(0);
|
||||
await expect(page.getByText("Status Distribution")).toBeVisible();
|
||||
await expectStableScreenshot(page, "dashboard-dispatcher.png");
|
||||
});
|
||||
});
|
||||
221
e2e/vendor-portal/portal-lifecycle.spec.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const PORTAL_TOKEN = "portal-e2e-token";
|
||||
|
||||
const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
interface DispatchDetail {
|
||||
id: number;
|
||||
dispatchNumber: string;
|
||||
workOrderTitle?: string;
|
||||
internalWONumber?: string;
|
||||
status: string;
|
||||
nteAmount?: number;
|
||||
scheduledDate?: string;
|
||||
poNumber?: string;
|
||||
dispatchedAt?: string;
|
||||
completedDate?: string;
|
||||
refusedAt?: string;
|
||||
statusUpdatedAt?: string;
|
||||
description?: string;
|
||||
workOrder?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
dispatcherContact?: { name?: string; email?: string; phone?: string };
|
||||
checklist: never[];
|
||||
comments: never[];
|
||||
upliftRequests: never[];
|
||||
signoffs: never[];
|
||||
documents: never[];
|
||||
}
|
||||
|
||||
const sentDetail: DispatchDetail = {
|
||||
id: 101,
|
||||
dispatchNumber: "DSP-101",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
status: "Sent",
|
||||
nteAmount: 1250,
|
||||
scheduledDate: futureDate,
|
||||
poNumber: "PO-555",
|
||||
statusUpdatedAt: "2026-08-09T16:30:00Z",
|
||||
description: "Boiler is leaking; vendor visit required.",
|
||||
workOrder: {
|
||||
workerOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
trade: "Plumbing",
|
||||
subTrade: "Backflow Preventers",
|
||||
siteCode: "STL-01",
|
||||
service: "Boiler Repair",
|
||||
},
|
||||
location: {
|
||||
name: "Central Distribution Center",
|
||||
address: "100 Industrial Pkwy",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zipCode: "63101",
|
||||
},
|
||||
dispatcherContact: {
|
||||
name: "Dana Field",
|
||||
email: "dana@seahavenind.com",
|
||||
phone: "314-555-0142",
|
||||
},
|
||||
checklist: [],
|
||||
comments: [],
|
||||
upliftRequests: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const listDispatches = [
|
||||
{
|
||||
id: 101,
|
||||
dispatchNumber: "DSP-101",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
internalWONumber: "WO-8842",
|
||||
locationName: "Central Distribution Center",
|
||||
locationCity: "St. Louis",
|
||||
locationState: "MO",
|
||||
status: "Sent",
|
||||
nteAmount: 1250,
|
||||
scheduledDate: futureDate,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
dispatchNumber: "DSP-102",
|
||||
workOrderTitle: "HVAC quarterly service",
|
||||
status: "Refused",
|
||||
refusedAt: "2026-08-08T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
dispatchNumber: "DSP-103",
|
||||
workOrderTitle: "PastAcknowledged job",
|
||||
status: "Acknowledged",
|
||||
scheduledDate: pastDate,
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfill(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) {
|
||||
const detailState: { current: DispatchDetail } = { current: { ...sentDetail } };
|
||||
|
||||
await page.route("**/api/vendor-portal/session", (route) => {
|
||||
if (options.sessionError) {
|
||||
fulfill(route, { message: "Invalid or expired token" }, 401);
|
||||
return;
|
||||
}
|
||||
fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => {
|
||||
const body = route.request().postDataJSON() as { reason?: string };
|
||||
detailState.current = {
|
||||
...detailState.current,
|
||||
status: "Refused",
|
||||
refusedAt: new Date().toISOString(),
|
||||
};
|
||||
fulfill(route, {
|
||||
data: {
|
||||
id: detailState.current.id,
|
||||
status: "Refused",
|
||||
refusedAt: detailState.current.refusedAt,
|
||||
reason: body?.reason,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) =>
|
||||
fulfill(route, { data: detailState.current }),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
|
||||
fulfill(route, { data: listDispatches }),
|
||||
);
|
||||
|
||||
return detailState;
|
||||
}
|
||||
|
||||
test.describe("Vendor portal lifecycle", () => {
|
||||
test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPortalApi(page);
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^Upcoming/ }).click();
|
||||
await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible();
|
||||
await expect(page.getByText("DSP-103")).toHaveCount(0);
|
||||
|
||||
await page.getByText("DSP-101 — Emergency boiler repair").click();
|
||||
await expect(page).toHaveURL(/\/dispatch\/101$/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible();
|
||||
await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("STL-01")).toBeVisible();
|
||||
await expect(page.getByText("$1250.00").first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible();
|
||||
await expect(page.getByText("Dana Field")).toBeVisible();
|
||||
await expect(page.getByText("314-555-0142")).toBeVisible();
|
||||
await expect(page.getByText(/Status updated/i)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week.");
|
||||
await expect(dialog.getByText(/\d+\/500/)).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Refuse dispatch" }).click();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/refused and locked/i)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("covers the refuse dialog at a mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockPortalApi(page);
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await page.getByText("DSP-101 — Emergency boiler repair").click();
|
||||
await expect(page).toHaveURL(/\/dispatch\/101$/);
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Refuse dispatch" })
|
||||
.getByRole("button", {
|
||||
name: "Refuse dispatch",
|
||||
})
|
||||
.click();
|
||||
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/refused and locked/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows an expired-token state when the session is rejected", async ({ page }) => {
|
||||
await mockPortalApi(page, { sessionError: true });
|
||||
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText(/invalid or expired/i);
|
||||
});
|
||||
});
|
||||
567
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-operations-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor Operations Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function authenticate(page: Page) {
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
await page.route("**/api/Dashboard/Stats", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
openWorkOrders: 0,
|
||||
pendingApprovals: 0,
|
||||
scheduledPMs: 0,
|
||||
activeAssets: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||
}
|
||||
|
||||
test.describe("Vendor operations", () => {
|
||||
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/insights*", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
const vendorLinks = page.locator('a[href^="/vendors"]');
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
|
||||
).not.toHaveAttribute("aria-current", "page");
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
|
||||
).not.toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 700, height: 900 });
|
||||
await authenticate(page);
|
||||
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
|
||||
await page.route("**/api/Vendor/GetVendorList*", (route) =>
|
||||
fulfillJson(route, { Data: [], TotalCount: 0 }),
|
||||
);
|
||||
|
||||
await page.goto("/vendor-pos");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
|
||||
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
|
||||
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
|
||||
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
|
||||
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
|
||||
.evaluate((element) => element.clientWidth),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||
fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "no-vendor-501",
|
||||
type: "NoVendor",
|
||||
severity: "Medium",
|
||||
group: "No Vendor",
|
||||
title: "No vendor assigned — 48h to service",
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
scheduledAt: "2026-07-25T12:00:00Z",
|
||||
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
||||
},
|
||||
{
|
||||
id: "vendor-conflict-801-802",
|
||||
type: "VendorConflict",
|
||||
severity: "Medium",
|
||||
group: "Vendor Conflict",
|
||||
title: "Vendor has overlapping appointments",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
workOrders: [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T13:00:00Z",
|
||||
end: "2026-07-25T15:00:00Z",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ label: "WO-501", href: "/workorders/501" },
|
||||
{ label: "WO-502", href: "/workorders/502" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/notifications");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/edit/501?assignVendor=1",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/501",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/502",
|
||||
);
|
||||
});
|
||||
|
||||
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
const requestedUrls: string[] = [];
|
||||
await page.route("**/api/vendor-operations/insights*", async (route) => {
|
||||
requestedUrls.push(route.request().url());
|
||||
const { pathname } = new URL(route.request().url());
|
||||
if (pathname.endsWith(".csv")) {
|
||||
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith(".pdf")) {
|
||||
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
vendorId: 7,
|
||||
vendor: "Gateway Plumbing",
|
||||
completedJobs: 12,
|
||||
cancellationRefusalRate: 0.1,
|
||||
onTimeArrivalRate: 0.9,
|
||||
onTimeCompletionRate: 0.8,
|
||||
averageCycleHours: 5.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
|
||||
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
|
||||
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
|
||||
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
|
||||
|
||||
const insightsQueryHas = (name: string, value: string) =>
|
||||
requestedUrls.some(
|
||||
(url) =>
|
||||
new URL(url).pathname.endsWith("/insights") &&
|
||||
new URL(url).searchParams.get(name) === value,
|
||||
);
|
||||
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
|
||||
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
|
||||
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Export CSV" }).click();
|
||||
await page.getByRole("button", { name: "Export PDF" }).click();
|
||||
|
||||
const exportRequest = (format: "csv" | "pdf") =>
|
||||
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
|
||||
const exportHasFilters = (url: string | undefined) => {
|
||||
if (!url) return false;
|
||||
const params = new URL(url).searchParams;
|
||||
return (
|
||||
params.get("from") === "2026-01-01" &&
|
||||
params.get("to") === "2026-01-31" &&
|
||||
params.get("trade") === "Plumbing"
|
||||
);
|
||||
};
|
||||
|
||||
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
|
||||
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
test("reorders and saves preferred vendors for a site", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
let savedPreferences: unknown;
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Vendor/Dropdown*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
|
||||
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
savedPreferences = route.request().postDataJSON();
|
||||
await fulfillJson(route, savedPreferences);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, [
|
||||
{
|
||||
id: 1,
|
||||
locationId: 10,
|
||||
vendorId: 7,
|
||||
vendorName: "Gateway Plumbing",
|
||||
trade: "Plumbing",
|
||||
sortOrder: 0,
|
||||
isStale: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
locationId: 10,
|
||||
vendorId: 8,
|
||||
vendorName: "Metro HVAC",
|
||||
trade: "HVAC",
|
||||
sortOrder: 1,
|
||||
isStale: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await page.goto("/vendors/preferences");
|
||||
await page.getByLabel("Site").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save preferences" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => savedPreferences)
|
||||
.toEqual([
|
||||
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
|
||||
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
let assignment: unknown;
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 900,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON();
|
||||
await fulfillJson(route, {
|
||||
availabilityStatus: "Available",
|
||||
conflicts: [
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 900,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/new");
|
||||
await page.getByLabel("Title").fill("Boiler repair");
|
||||
await page.getByLabel("Location").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await page.getByLabel("Priority").click();
|
||||
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||
await page.getByLabel("Scheduled").fill("2026-07-25");
|
||||
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
|
||||
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
|
||||
await page.getByLabel("Manual ETA override").check();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => assignment)
|
||||
.toEqual({
|
||||
vendorId: 7,
|
||||
scheduledStart: "2026-07-25T10:00:00.000Z",
|
||||
scheduledEnd: "2026-07-25T12:00:00.000Z",
|
||||
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
|
||||
etaManualOverride: true,
|
||||
});
|
||||
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/workorders\/900$/);
|
||||
});
|
||||
|
||||
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
|
||||
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||
const page = await context.newPage();
|
||||
await authenticate(page);
|
||||
let assignment: Record<string, unknown> | undefined;
|
||||
|
||||
const workOrder = {
|
||||
Id: 901,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
CreatedDate: "2026-07-20",
|
||||
ScheduledDate: "2026-07-25",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [
|
||||
{
|
||||
Id: 44,
|
||||
VendorId: 7,
|
||||
Status: "Acknowledged",
|
||||
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
|
||||
EtaManualOverride: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, { data: workOrder }),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
|
||||
fulfillJson(route, { data: workOrder }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||
});
|
||||
|
||||
await page.goto("/workorders/edit/901");
|
||||
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
|
||||
await page.getByRole("button", { name: "Update" }).click();
|
||||
|
||||
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
|
||||
// The window below straddles the transition: 01:00 is EST (UTC-5) while
|
||||
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
|
||||
// fixed-window assumption remains.
|
||||
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||
const page = await context.newPage();
|
||||
await authenticate(page);
|
||||
let assignment: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 902,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||
});
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 902,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/new");
|
||||
await page.getByLabel("Title").fill("Boiler repair");
|
||||
await page.getByLabel("Location").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await page.getByLabel("Priority").click();
|
||||
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
|
||||
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
|
||||
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 501,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/edit/501?assignVendor=1");
|
||||
|
||||
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
|
||||
await expect(vendorSection).toBeVisible();
|
||||
await expect(vendorSection).toBeFocused();
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
|
||||
await expect(option).toContainText("Unknown");
|
||||
});
|
||||
});
|
||||
378
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const INTERNAL_USER = {
|
||||
token: "uplift-approver-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "approver@seahavenind.com",
|
||||
userRoles: "Admin,Tier2Approver",
|
||||
fullname: "Uplift Approver",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }),
|
||||
});
|
||||
}
|
||||
|
||||
test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
let uploadPurpose = "";
|
||||
let created = false;
|
||||
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") {
|
||||
uploadPurpose = request.postData() ?? "";
|
||||
await fulfillJson(route, {
|
||||
id: 22,
|
||||
originalFileName: "estimate.pdf",
|
||||
scanStatus: "Pending",
|
||||
reviewStatus: "Processing",
|
||||
purpose: "UpliftEvidence",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/documents/22/status")) {
|
||||
await fulfillJson(route, {
|
||||
id: 22,
|
||||
originalFileName: "estimate.pdf",
|
||||
scanStatus: "Passed",
|
||||
reviewStatus: "Approved",
|
||||
purpose: "UpliftEvidence",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
|
||||
requestBody = request.postDataJSON();
|
||||
created = true;
|
||||
await fulfillJson(route, {
|
||||
id: 31,
|
||||
status: "Pending",
|
||||
noApprovalRequired: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
poNumber: "PO-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: created
|
||||
? [
|
||||
{
|
||||
id: 31,
|
||||
status: "Pending",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Replacement compressor",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
notificationStatus: "Sent",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible();
|
||||
|
||||
await page.getByLabel("New total NTE").fill("125");
|
||||
await page.getByLabel("Reason").fill("Replacement compressor");
|
||||
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
|
||||
name: "estimate.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
|
||||
});
|
||||
await page.getByRole("button", { name: "Request uplift" }).click();
|
||||
|
||||
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
|
||||
expect(uploadPurpose).toContain('name="purpose"');
|
||||
expect(uploadPurpose).toContain("UpliftEvidence");
|
||||
expect(requestBody).toMatchObject({
|
||||
requestedNTE: 125,
|
||||
reason: "Replacement compressor",
|
||||
evidenceDocumentId: 22,
|
||||
});
|
||||
expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => {
|
||||
let withdrawnPath = "";
|
||||
let pending = true;
|
||||
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) {
|
||||
withdrawnPath = path;
|
||||
pending = false;
|
||||
await fulfillJson(route, { id: 31, status: "Withdrawn" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [
|
||||
{
|
||||
id: 31,
|
||||
status: pending ? "Pending" : "Withdrawn",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Parts",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByRole("button", { name: "Withdraw request" }).click();
|
||||
|
||||
await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible();
|
||||
expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw");
|
||||
});
|
||||
|
||||
test("vendor receives the no-approval result for a request within the current NTE", async ({
|
||||
page,
|
||||
}) => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
|
||||
requestBody = request.postDataJSON();
|
||||
await fulfillJson(route, { noApprovalRequired: true, status: "Approved" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByLabel("New total NTE").fill("90");
|
||||
await expect(page.getByLabel("Reason")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Request uplift" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("No approval required — the existing NTE already covers this amount."),
|
||||
).toBeVisible();
|
||||
expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" });
|
||||
expect(requestBody).not.toHaveProperty("evidenceDocumentId");
|
||||
});
|
||||
|
||||
test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => {
|
||||
let revisionBody: Record<string, unknown> | undefined;
|
||||
let revised = false;
|
||||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7/uplift-request/31/revise")) {
|
||||
revisionBody = request.postDataJSON();
|
||||
revised = true;
|
||||
await fulfillJson(route, { id: 31, status: "Pending" });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/dispatches/7")) {
|
||||
await fulfillJson(route, {
|
||||
id: 7,
|
||||
dispatchNumber: "DSP-7",
|
||||
status: "In Progress",
|
||||
nteAmount: 100,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
upliftRequests: [
|
||||
{
|
||||
id: 31,
|
||||
status: revised ? "Pending" : "ChangesRequested",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
vendorReason: "Parts",
|
||||
decisionNote: "Itemize the estimate",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.goto("/v/portal-token/dispatch/7");
|
||||
await page.getByRole("button", { name: "Revise" }).click();
|
||||
await page.getByLabel("New total NTE").fill("120");
|
||||
await page.getByLabel("Reason").fill("Parts itemized in the attached estimate");
|
||||
await page.getByRole("button", { name: "Submit revision" }).click();
|
||||
|
||||
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
|
||||
expect(revisionBody).toEqual({
|
||||
requestedNTE: 120,
|
||||
reason: "Parts itemized in the attached estimate",
|
||||
evidenceDocumentId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => {
|
||||
const decisions: Array<{ path: string; body: unknown }> = [];
|
||||
const statuses = new Map<number, string>([
|
||||
[41, "Pending"],
|
||||
[42, "Pending"],
|
||||
]);
|
||||
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
INTERNAL_USER,
|
||||
);
|
||||
await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {}));
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, []));
|
||||
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/);
|
||||
if (action && request.method() === "POST") {
|
||||
const id = Number(action[1]);
|
||||
statuses.set(
|
||||
id,
|
||||
action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected",
|
||||
);
|
||||
decisions.push({ path, body: request.postDataJSON() });
|
||||
await fulfillJson(route, { id, status: statuses.get(id) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/uplifts/can-approve")) {
|
||||
await fulfillJson(route, { canApprove: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedStatus = new URL(request.url()).searchParams.get("status");
|
||||
const items = [
|
||||
{
|
||||
id: 41,
|
||||
dispatchId: 7,
|
||||
workOrderId: 501,
|
||||
dispatchNumber: "DSP-41",
|
||||
poNumber: "PO-41",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 125,
|
||||
delta: 25,
|
||||
requiredTier: 1,
|
||||
status: statuses.get(41),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 22,
|
||||
evidenceFileName: "estimate.pdf",
|
||||
evidenceContentType: "application/pdf",
|
||||
evidenceSizeBytes: 2048,
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
dispatchId: 8,
|
||||
workOrderId: 502,
|
||||
dispatchNumber: "DSP-42",
|
||||
poNumber: "PO-42",
|
||||
vendorCompanyName: "Metro HVAC",
|
||||
currentNTE: 200,
|
||||
requestedNTE: 260,
|
||||
delta: 60,
|
||||
requiredTier: 2,
|
||||
status: statuses.get(42),
|
||||
canDecide: true,
|
||||
evidenceDocumentId: 23,
|
||||
evidenceFileName: "quote.pdf",
|
||||
},
|
||||
].filter((item) => requestedStatus == null || item.status === requestedStatus);
|
||||
|
||||
await fulfillJson(route, {
|
||||
total: items.length,
|
||||
items,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/uplifts");
|
||||
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
|
||||
|
||||
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||
await approvalRow.getByRole("button", { name: "Approve" }).click();
|
||||
|
||||
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
|
||||
await changesRow.getByRole("button", { name: "Reject" }).click();
|
||||
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
|
||||
await page.getByRole("button", { name: "Reject uplift" }).click();
|
||||
|
||||
await page.getByRole("tab", { name: "Approved" }).click();
|
||||
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
|
||||
await approvedRow.getByRole("button", { name: "Revoke" }).click();
|
||||
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
|
||||
await page.getByRole("button", { name: "Revoke $125" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => decisions)
|
||||
.toEqual([
|
||||
{ path: "/api/uplifts/41/approve", body: {} },
|
||||
{
|
||||
path: "/api/uplifts/42/reject",
|
||||
body: { note: "Please itemize labor and parts." },
|
||||
},
|
||||
{
|
||||
path: "/api/uplifts/41/revoke",
|
||||
body: { note: "Approval was made in error." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
984
e2e/vendors/vendors.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const activeVendors = [
|
||||
{
|
||||
Id: 1,
|
||||
CompanyId: 101,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
Email: "dispatch@gateway.test",
|
||||
Phone: "314-555-0198",
|
||||
CompanyPhone: "314-555-0100",
|
||||
Address: "1 Market St",
|
||||
City: "St. Louis",
|
||||
State: "MO",
|
||||
Zip: "63101",
|
||||
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
TotalJobs: 72,
|
||||
IsActive: true,
|
||||
PreferredContact: "Email",
|
||||
AreaId: 1 as number | null,
|
||||
AreaName: "East",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CompanyId: 102,
|
||||
CompanyName: "Metro HVAC",
|
||||
ContactName: "Maya Chen",
|
||||
Email: "maya@metro.test",
|
||||
Phone: "314-555-0112",
|
||||
CompanyPhone: "314-555-0110",
|
||||
Address: "2 Pine St",
|
||||
City: "Clayton",
|
||||
State: "MO",
|
||||
Zip: "63105",
|
||||
TradeSpecialties: "HVAC",
|
||||
GoogleMapsUrl: "",
|
||||
Notes: "",
|
||||
TotalJobs: 18,
|
||||
IsActive: true,
|
||||
AreaId: null as number | null,
|
||||
AreaName: "",
|
||||
},
|
||||
];
|
||||
|
||||
const SERVICE_AREAS = [
|
||||
{ id: 2, name: "Central" },
|
||||
{ id: 1, name: "East" },
|
||||
];
|
||||
|
||||
const inactiveVendors = [
|
||||
{
|
||||
...activeVendors[0],
|
||||
Id: 3,
|
||||
CompanyName: "Drain Cleaning Bros",
|
||||
ContactName: "Drew Banks",
|
||||
TotalJobs: 151,
|
||||
IsActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const LONG_TEXT_VENDOR = {
|
||||
CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis",
|
||||
ContactName: "Adam Christopher Whyte Senior Field Technician",
|
||||
Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example",
|
||||
Phone: "+1 (314) 555-0198 extension 123456",
|
||||
Address: "New York, NY, Estados Unidos, 123 Long Address Avenue",
|
||||
City: "",
|
||||
State: "",
|
||||
};
|
||||
|
||||
interface MockState {
|
||||
listUrls: string[];
|
||||
createdBody?: Record<string, unknown>;
|
||||
updatedBody?: Record<string, unknown>;
|
||||
patchedBody?: Record<string, unknown>;
|
||||
patchedCompanyId?: string;
|
||||
deletedId?: string;
|
||||
deleteConfirmedOpenWorkOrders?: boolean;
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function captureVisual(page: Page, path: string) {
|
||||
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
}
|
||||
|
||||
async function mockVendorApi(
|
||||
page: Page,
|
||||
options: {
|
||||
listError?: boolean;
|
||||
deactivationBlocked?: boolean;
|
||||
deleteConflict?: boolean;
|
||||
groupedDirectory?: boolean;
|
||||
legacyGateway?: boolean;
|
||||
longText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const state: MockState = { listUrls: [] };
|
||||
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||||
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR);
|
||||
if (options.legacyGateway) {
|
||||
vendorRecords[0].Phone = "555-1234 ext 9";
|
||||
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
||||
}
|
||||
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "Plumbing" },
|
||||
{ id: 2, value: "HVAC" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/Vendor/facets*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
companies: activeVendors.map((vendor) => ({
|
||||
companyId: vendor.CompanyId,
|
||||
name: vendor.CompanyName,
|
||||
companyPhone: vendor.CompanyPhone,
|
||||
email: vendor.Email,
|
||||
address: vendor.Address,
|
||||
city: vendor.City,
|
||||
state: vendor.State,
|
||||
zip: vendor.Zip,
|
||||
googleMapsUrl: vendor.GoogleMapsUrl,
|
||||
})),
|
||||
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||
areas: SERVICE_AREAS,
|
||||
locations: [
|
||||
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||
],
|
||||
jobBuckets: [
|
||||
{ id: "under-50", label: "Under 50" },
|
||||
{ id: "50-99", label: "50–99" },
|
||||
{ id: "100-149", label: "100–149" },
|
||||
{ id: "150-plus", label: "150+" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
state.listUrls.push(url.toString());
|
||||
if (options.listError && url.searchParams.get("pageSize") !== "1") {
|
||||
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = url.searchParams.get("isActive") !== "false";
|
||||
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
|
||||
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||
const company = url.searchParams.get("companies[0]");
|
||||
const areas = [...url.searchParams.entries()]
|
||||
.filter(([key]) => key.startsWith("areas["))
|
||||
.map(([, value]) => value);
|
||||
const filtered = source.filter(
|
||||
(vendor) =>
|
||||
(!search ||
|
||||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
|
||||
.toLowerCase()
|
||||
.includes(search)) &&
|
||||
(!company || vendor.CompanyName === company) &&
|
||||
(areas.length === 0 ||
|
||||
areas.some((area) =>
|
||||
area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area,
|
||||
)),
|
||||
);
|
||||
const isCount = url.searchParams.get("pageSize") === "1";
|
||||
const responseRows = filtered.map((vendor) => {
|
||||
if (!options.groupedDirectory) return vendor;
|
||||
const technicians = [
|
||||
{
|
||||
Id: vendor.Id,
|
||||
ContactName: vendor.ContactName,
|
||||
Email: vendor.Email,
|
||||
Phone: vendor.Phone,
|
||||
TradeSpecialties: vendor.TradeSpecialties,
|
||||
IsActive: vendor.IsActive,
|
||||
TotalJobs: vendor.TotalJobs,
|
||||
},
|
||||
];
|
||||
if (vendor.Id === 1) {
|
||||
technicians.push({
|
||||
Id: 91,
|
||||
ContactName: "Robin Rivers",
|
||||
Email: "robin@gateway.test",
|
||||
Phone: "314-555-0191",
|
||||
TradeSpecialties: "Drain Cleaning",
|
||||
IsActive: true,
|
||||
TotalJobs: 18,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...vendor,
|
||||
TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0),
|
||||
Technicians: technicians,
|
||||
};
|
||||
});
|
||||
await fulfillJson(route, {
|
||||
Data: isCount ? responseRows.slice(0, 1) : responseRows,
|
||||
TotalCount: filtered.length,
|
||||
PageNumber: 1,
|
||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1];
|
||||
|
||||
if (request.method() === "POST") {
|
||||
state.createdBody = request.postDataJSON();
|
||||
await fulfillJson(route, {
|
||||
...state.createdBody,
|
||||
companyId: 999,
|
||||
rowVersion: "rv-created",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "PATCH" && pathCompanyId) {
|
||||
state.patchedBody = request.postDataJSON();
|
||||
state.patchedCompanyId = pathCompanyId;
|
||||
const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId);
|
||||
if (!anchor) {
|
||||
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||||
return;
|
||||
}
|
||||
const added = Array.isArray(state.patchedBody.addTechnicians)
|
||||
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
await fulfillJson(route, {
|
||||
companyId: anchor.CompanyId,
|
||||
rowVersion: "rv-patched",
|
||||
name: anchor.CompanyName,
|
||||
companyPhone: anchor.CompanyPhone,
|
||||
email: anchor.Email,
|
||||
address: anchor.Address,
|
||||
city: anchor.City,
|
||||
state: anchor.State,
|
||||
zip: anchor.Zip,
|
||||
googleMapsUrl: anchor.GoogleMapsUrl,
|
||||
notes: anchor.Notes,
|
||||
areaId: anchor.AreaId,
|
||||
areaName: anchor.AreaName,
|
||||
technicians: [
|
||||
...vendorRecords
|
||||
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||||
.map((vendor) => ({
|
||||
id: vendor.Id,
|
||||
contactName: vendor.ContactName,
|
||||
phone: vendor.Phone,
|
||||
email: vendor.Email,
|
||||
preferredContact: vendor.PreferredContact ?? "Phone",
|
||||
tradeSpecialties: vendor.TradeSpecialties,
|
||||
isActive: vendor.IsActive,
|
||||
totalJobs: vendor.TotalJobs,
|
||||
})),
|
||||
...added.map((technician, index) => ({
|
||||
id: 900 + index,
|
||||
totalJobs: 0,
|
||||
...technician,
|
||||
})),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "PUT" && pathCompanyId) {
|
||||
state.updatedBody = request.postDataJSON();
|
||||
const technicians = Array.isArray(state.updatedBody.technicians)
|
||||
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
for (const technician of technicians) {
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id));
|
||||
if (vendor && typeof technician.isActive === "boolean") {
|
||||
vendor.IsActive = technician.isActive;
|
||||
}
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
...state.updatedBody,
|
||||
companyId: Number(pathCompanyId),
|
||||
rowVersion: "rv-updated",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vendorId = url.searchParams.get("vendorId");
|
||||
const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId;
|
||||
const anchor = vendorId
|
||||
? vendorRecords.find((vendor) => String(vendor.Id) === vendorId)
|
||||
: vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId);
|
||||
if (!anchor) {
|
||||
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
companyId: anchor.CompanyId,
|
||||
rowVersion: "rv-1",
|
||||
name: anchor.CompanyName,
|
||||
companyPhone: anchor.CompanyPhone,
|
||||
email: anchor.Email,
|
||||
address: anchor.Address,
|
||||
city: anchor.City,
|
||||
state: anchor.State,
|
||||
zip: anchor.Zip,
|
||||
googleMapsUrl: anchor.GoogleMapsUrl,
|
||||
notes: anchor.Notes,
|
||||
areaId: anchor.AreaId,
|
||||
areaName: anchor.AreaName,
|
||||
technicians: vendorRecords
|
||||
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||||
.map((vendor) => ({
|
||||
id: vendor.Id,
|
||||
contactName: vendor.ContactName,
|
||||
phone: vendor.Phone,
|
||||
email: vendor.Email,
|
||||
preferredContact: vendor.PreferredContact ?? "Phone",
|
||||
tradeSpecialties: vendor.TradeSpecialties,
|
||||
isActive: vendor.IsActive,
|
||||
totalJobs: vendor.TotalJobs,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
vendorId,
|
||||
canDeactivate: !options.deactivationBlocked,
|
||||
openWorkOrders: options.deactivationBlocked
|
||||
? [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
status: "Dispatched",
|
||||
scheduledDate: "2026-07-24T14:00:00Z",
|
||||
dispatchId: 801,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
portalUrl: "https://vendor.example.test/token",
|
||||
issuedAt: "2026-07-31T12:00:00Z",
|
||||
expiresAt: "2026-08-07T12:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const id = requestUrl.pathname.split("/").pop() ?? "";
|
||||
if (route.request().method() === "PUT") {
|
||||
state.updatedBody = route.request().postDataJSON();
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
|
||||
vendor.IsActive = state.updatedBody.isActive;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
state.deleteConfirmedOpenWorkOrders =
|
||||
requestUrl.searchParams.get("confirmOpenWorkOrders") === "true";
|
||||
if (options.deleteConflict) {
|
||||
await fulfillJson(
|
||||
route,
|
||||
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||||
409,
|
||||
);
|
||||
return;
|
||||
}
|
||||
state.deletedId = id;
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
if (options.legacyGateway && vendor) vendor.IsActive = false;
|
||||
await fulfillJson(route, { status: "200" });
|
||||
return;
|
||||
}
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
await fulfillJson(route, { data: vendor });
|
||||
});
|
||||
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
state.createdBody = route.request().postDataJSON();
|
||||
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test.describe("Vendor directory prototype parity", () => {
|
||||
test("renders company-owned columns and the grouped technician roster", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await mockVendorApi(page, { groupedDirectory: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
const expectedHeaders = [
|
||||
"Company",
|
||||
"Company Phone",
|
||||
"Company Email",
|
||||
"Street Address",
|
||||
"City",
|
||||
"State",
|
||||
"Service Area",
|
||||
"Trade",
|
||||
"Technician",
|
||||
"Technician Phone",
|
||||
"Total Jobs",
|
||||
"Status",
|
||||
];
|
||||
await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1);
|
||||
for (const header of expectedHeaders) {
|
||||
await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const gatewayRow = page.getByRole("button", {
|
||||
name: "Open vendor details for Gateway Plumbing",
|
||||
});
|
||||
await expect(gatewayRow).toContainText("Adam Whyte");
|
||||
await expect(gatewayRow).toContainText("90");
|
||||
const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge");
|
||||
await expect(rosterBadge).toHaveText("+1");
|
||||
await rosterBadge.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
|
||||
await rosterBadge.focus();
|
||||
await expect(rosterBadge).toBeFocused();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
|
||||
});
|
||||
|
||||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
||||
await expect(heading).toHaveCSS("font-size", "30px");
|
||||
await expect(heading).toHaveCSS("font-weight", "800");
|
||||
await expect(heading).toHaveCSS("line-height", "33px");
|
||||
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
||||
await expect(page.getByText("2 active companies").first()).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
for (const heading of [
|
||||
"Company",
|
||||
"Company Phone",
|
||||
"Company Email",
|
||||
"Street Address",
|
||||
"City",
|
||||
"State",
|
||||
"Service Area",
|
||||
"Trade",
|
||||
"Technician",
|
||||
"Technician Phone",
|
||||
"Total Jobs",
|
||||
]) {
|
||||
await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
||||
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
||||
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
||||
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
||||
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
||||
expect((await page.locator("table").boundingBox())?.width).toBe(2200);
|
||||
expect(
|
||||
(await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox())
|
||||
?.height,
|
||||
).toBe(47);
|
||||
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
|
||||
await expect(tradeBadge).toHaveText("+1");
|
||||
await tradeBadge.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
|
||||
await tradeBadge.focus();
|
||||
await expect(tradeBadge).toBeFocused();
|
||||
await tradeBadge.click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await tradeBadge.press("Enter");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByText("2 active companies").last()).toBeVisible();
|
||||
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
|
||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade…").clear();
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||
await page.getByRole("button", { name: /Company/ }).click();
|
||||
await page.getByPlaceholder("All companies").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
await expect(page.getByText("1 company · filtered by")).toBeVisible();
|
||||
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
|
||||
expect(
|
||||
state.listUrls.some(
|
||||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||
await expect(page.getByText("No inactive companies found")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear all" }).click();
|
||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("truncates every non-abbreviated textual column", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await mockVendorApi(page, { longText: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
const row = page.locator("tbody tr").first();
|
||||
const fields = [
|
||||
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
|
||||
{ cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email },
|
||||
{ cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address },
|
||||
{ cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
|
||||
{ cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
|
||||
];
|
||||
|
||||
for (const expected of fields) {
|
||||
const cell = row.locator("td").nth(expected.cellIndex);
|
||||
const field = cell.getByTestId(expected.testId);
|
||||
await expect(field).toBeVisible();
|
||||
|
||||
const metrics = await field.evaluate((element) => {
|
||||
const fieldRect = element.getBoundingClientRect();
|
||||
const cellRect = element.closest("td")?.getBoundingClientRect();
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
withinCell:
|
||||
cellRect !== undefined &&
|
||||
fieldRect.left >= cellRect.left - 1 &&
|
||||
fieldRect.right <= cellRect.right + 1,
|
||||
};
|
||||
});
|
||||
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
|
||||
expect(metrics.withinCell).toBe(true);
|
||||
|
||||
await field.hover();
|
||||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
|
||||
const companyLabel = page
|
||||
.getByRole("dialog", { name: /Add Vendor/ })
|
||||
.locator("label")
|
||||
.filter({ hasText: "Company (required)" });
|
||||
await expect(companyLabel).toBeVisible();
|
||||
const companyLabelMetrics = await companyLabel.evaluate((element) => {
|
||||
const field = element.closest(".MuiFormControl-root");
|
||||
const labelRect = element.getBoundingClientRect();
|
||||
const fieldRect = field?.getBoundingClientRect();
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
withinFieldGeometry: fieldRect
|
||||
? labelRect.left >= fieldRect.left &&
|
||||
labelRect.right <= fieldRect.right &&
|
||||
labelRect.top >= fieldRect.top &&
|
||||
labelRect.bottom <= fieldRect.bottom
|
||||
: false,
|
||||
};
|
||||
});
|
||||
expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth);
|
||||
expect(companyLabelMetrics.withinFieldGeometry).toBe(true);
|
||||
|
||||
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||
const floatedCompanyLabel = page
|
||||
.getByRole("dialog", { name: /Add Vendor/ })
|
||||
.locator("label")
|
||||
.filter({ hasText: "Company (required)" });
|
||||
const floatedCompanyField = floatedCompanyLabel.locator("..");
|
||||
await expect(floatedCompanyLabel).toHaveText("Company (required)");
|
||||
await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)");
|
||||
const floatedMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||||
const labelRect = element.getBoundingClientRect();
|
||||
const legendRect = element
|
||||
.closest(".MuiFormControl-root")
|
||||
?.querySelector("legend")
|
||||
?.getBoundingClientRect();
|
||||
return {
|
||||
labelClientWidth: element.clientWidth,
|
||||
labelScrollWidth: element.scrollWidth,
|
||||
legendWidth: legendRect?.width ?? 0,
|
||||
labelWidth: labelRect.width,
|
||||
};
|
||||
});
|
||||
expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth);
|
||||
expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth);
|
||||
|
||||
// Scrolling focus down the form and back to Company must keep the floated label
|
||||
// inside the dialog's visible scrollport.
|
||||
const dialogContent = page
|
||||
.getByRole("dialog", { name: /Add Vendor/ })
|
||||
.locator(".MuiDialogContent-root");
|
||||
await page.getByLabel("Technician name (optional)").click();
|
||||
await expect
|
||||
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||||
await expect
|
||||
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||||
.toBeLessThanOrEqual(1);
|
||||
await expect(floatedCompanyLabel).toBeVisible();
|
||||
const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||||
const content = element.closest(".MuiDialogContent-root");
|
||||
return {
|
||||
labelTop: element.getBoundingClientRect().top,
|
||||
labelBottom: element.getBoundingClientRect().bottom,
|
||||
dialogTop: content?.getBoundingClientRect().top ?? Number.NaN,
|
||||
dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN,
|
||||
overflowY: content ? getComputedStyle(content).overflowY : "",
|
||||
};
|
||||
});
|
||||
expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY);
|
||||
expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual(
|
||||
labelScrollportMetrics.dialogTop - 1,
|
||||
);
|
||||
expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual(
|
||||
labelScrollportMetrics.dialogBottom + 1,
|
||||
);
|
||||
|
||||
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||||
"dispatch@gateway.test",
|
||||
);
|
||||
// SH-271: one combined "Address (optional)" line became three required
|
||||
// fields, and the hand-pasted Google Maps URL input is gone — the location
|
||||
// is derived from the address now.
|
||||
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
|
||||
"1 Market St",
|
||||
);
|
||||
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
|
||||
await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)");
|
||||
await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East");
|
||||
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
|
||||
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Add technician" }).click();
|
||||
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||
await tradeInput.fill("Plumbing");
|
||||
await tradeInput.press("Enter");
|
||||
await tradeInput.fill("HVAC");
|
||||
await tradeInput.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /Move (Plumbing|HVAC) (up|down)/ })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||
expect(state.patchedCompanyId).toBe("101");
|
||||
expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" });
|
||||
// Only the field the user actually typed into is transmitted. Address/phone/email were
|
||||
// populated by selecting the company and never edited, so they must not be sent — otherwise a
|
||||
// concurrent edit by another user to any of them would be silently reverted on resubmit.
|
||||
expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" });
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("address");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("email");
|
||||
expect(state.patchedBody?.companyFields).not.toHaveProperty("name");
|
||||
expect(state.patchedBody?.addTechnicians).toEqual([
|
||||
expect.objectContaining({
|
||||
contactName: "New Technician",
|
||||
tradeSpecialties: "Plumbing, HVAC",
|
||||
}),
|
||||
]);
|
||||
const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians)
|
||||
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const newTechnician = patchedTechnicians.find(
|
||||
(technician) => technician.contactName === "New Technician",
|
||||
);
|
||||
expect(newTechnician?.preferredContact).toBeUndefined();
|
||||
expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(state.updatedBody).toBeUndefined();
|
||||
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||
// SH-271: the separate "Open in Google Maps" row is gone — the Street
|
||||
// Address itself is the link, built from the structured parts.
|
||||
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
|
||||
await expect(streetLink).toHaveAttribute("target", "_blank");
|
||||
await expect(streetLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
|
||||
);
|
||||
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||
|
||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/\/vendors$/);
|
||||
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||
});
|
||||
|
||||
test("filters by Service Area and lets an admin assign one", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const gatewayRow = page.getByRole("button", {
|
||||
name: "Open vendor details for Gateway Plumbing",
|
||||
});
|
||||
const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" });
|
||||
await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East");
|
||||
await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned");
|
||||
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await page.getByRole("button", { name: "Service Area" }).click();
|
||||
await page.getByPlaceholder("All areas").click();
|
||||
await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]);
|
||||
await page.getByRole("option", { name: "East" }).click();
|
||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Remove filter Service Area: East" }),
|
||||
).toBeVisible();
|
||||
await expect(gatewayRow).toBeVisible();
|
||||
await expect(metroRow).toHaveCount(0);
|
||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await page.getByRole("button", { name: "Service Area" }).click();
|
||||
await page.getByRole("button", { name: "Clear all" }).last().click();
|
||||
await page.getByPlaceholder("All areas").click();
|
||||
await page.getByRole("option", { name: "Unassigned" }).click();
|
||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||
await expect(page.getByText("Service Area: Unassigned")).toBeVisible();
|
||||
await expect(metroRow).toBeVisible();
|
||||
await expect(gatewayRow).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" });
|
||||
await expect(areaField).toBeEnabled();
|
||||
await areaField.click();
|
||||
await page.getByRole("option", { name: "Central" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect.poll(() => state.updatedBody?.areaId).toBe(2);
|
||||
expect(state.updatedBody?.state).toBe("MO");
|
||||
});
|
||||
|
||||
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
||||
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||
await expect.poll(() => state.deletedId).toBe("1");
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||||
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
||||
await addButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||
await expect(addButton).toBeFocused();
|
||||
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
|
||||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||||
// SH-271 made the structured address required. This test is about keyboard
|
||||
// dismissal and free-text company creation, so the address is filled to
|
||||
// leave those as the only things it is exercising.
|
||||
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
|
||||
await page.getByLabel("City (required)").fill("St. Louis");
|
||||
await page.getByLabel("State (required)").fill("Missouri");
|
||||
await page.getByRole("option", { name: "Missouri (MO)" }).click();
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
|
||||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||
expect(state.createdBody?.technicians).toEqual([]);
|
||||
expect(state.createdBody?.state).toBe("MO");
|
||||
expect(state.createdBody?.areaId).toBeNull();
|
||||
});
|
||||
|
||||
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {
|
||||
await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await expect(detailDrawer).toBeVisible();
|
||||
await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible();
|
||||
await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({
|
||||
page,
|
||||
}) => {
|
||||
const confirmState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" });
|
||||
await expect(dialog).toContainText("It still has 1 open work order");
|
||||
await expect(
|
||||
dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }),
|
||||
).toHaveAttribute("href", "/workorders/501");
|
||||
|
||||
// SH-254: the open work orders inform the decision, they no longer block it.
|
||||
const confirm = dialog.getByRole("button", { name: "Deactivate anyway" });
|
||||
await expect(confirm).toBeEnabled();
|
||||
await confirm.click();
|
||||
|
||||
await expect.poll(() => confirmState.deletedId).toBe("1");
|
||||
expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true);
|
||||
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
|
||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Deactivate this vendor?" })
|
||||
.getByRole("button", {
|
||||
name: /^Deactivate$/,
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText(
|
||||
/open work orders|conflict/i,
|
||||
);
|
||||
expect(racedState.deletedId).toBeUndefined();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockVendorApi(page, { listError: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
const mainBox = await page.getByRole("main").boundingBox();
|
||||
expect(mainBox).toMatchObject({ x: 0, width: 390 });
|
||||
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
|
||||
await expect(navigation).not.toBeVisible();
|
||||
|
||||
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
|
||||
await openNavigation.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(navigation).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(navigation).not.toBeVisible();
|
||||
await expect(openNavigation).toBeFocused();
|
||||
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/Vendor directory unavailable|failed|server error/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||
await expect(filterDrawer).toBeVisible();
|
||||
const box = await filterDrawer.boundingBox();
|
||||
expect(box?.width).toBeLessThanOrEqual(391);
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||
await page.getByRole("button", { name: "Close filters" }).click();
|
||||
});
|
||||
|
||||
test("keeps protected shell destinations available and marks their selected state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockVendorApi(page);
|
||||
|
||||
for (const destination of [
|
||||
{ path: "/workorders", label: "Work Orders" },
|
||||
{ path: "/accounts", label: "Accounts" },
|
||||
{ path: "/locations", label: "Sites" },
|
||||
{ path: "/notifications", label: "Notifications" },
|
||||
]) {
|
||||
await page.goto(destination.path);
|
||||
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
274
e2e/vendors/vendors.visual.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-visual-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor Visual Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const activeVendors = [
|
||||
{
|
||||
Id: 1,
|
||||
CompanyId: 101,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
Email: "dispatch@gateway.test",
|
||||
Phone: "314-555-0198",
|
||||
CompanyPhone: "314-555-0100",
|
||||
Address: "1 Market St",
|
||||
City: "St. Louis",
|
||||
State: "MO",
|
||||
Zip: "63101",
|
||||
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
TotalJobs: 72,
|
||||
IsActive: true,
|
||||
PreferredContact: "Email",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CompanyId: 102,
|
||||
CompanyName: "Metro HVAC",
|
||||
ContactName: "Maya Chen",
|
||||
Email: "maya@metro.test",
|
||||
Phone: "314-555-0112",
|
||||
CompanyPhone: "314-555-0110",
|
||||
Address: "2 Pine St",
|
||||
City: "Clayton",
|
||||
State: "MO",
|
||||
Zip: "63105",
|
||||
TradeSpecialties: "HVAC",
|
||||
GoogleMapsUrl: "",
|
||||
Notes: "",
|
||||
TotalJobs: 18,
|
||||
IsActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
const inactiveVendors = [
|
||||
{
|
||||
...activeVendors[0],
|
||||
Id: 3,
|
||||
CompanyId: 103,
|
||||
CompanyName: "Drain Cleaning Bros",
|
||||
ContactName: "Drew Banks",
|
||||
TotalJobs: 151,
|
||||
IsActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
// The vendor address map preview embeds maps.google.com in an iframe. Its
|
||||
// content depends on the network, on timing, and on whatever Google serves
|
||||
// that minute, none of which belongs in a test with "deterministic pixel
|
||||
// regression" in its name. Aborting the request pins the frame to a blank
|
||||
// box, so the shot measures our layout and nothing else.
|
||||
await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort());
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "Plumbing" },
|
||||
{ id: 2, value: "HVAC" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith("/api/Vendor/facets")) {
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
companies: activeVendors.map((vendor) => ({
|
||||
companyId: vendor.CompanyId,
|
||||
name: vendor.CompanyName,
|
||||
})),
|
||||
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||
locations: [
|
||||
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||
],
|
||||
jobBuckets: [
|
||||
{ id: "under-50", label: "Under 50" },
|
||||
{ id: "50-99", label: "50–99" },
|
||||
{ id: "100-149", label: "100–149" },
|
||||
{ id: "150-plus", label: "150+" },
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith("/api/Vendor/GetVendorDirectoryList")) {
|
||||
const isCount = url.searchParams.get("pageSize") === "1";
|
||||
if (mode === "error" && !isCount) {
|
||||
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
const source =
|
||||
mode === "empty"
|
||||
? []
|
||||
: url.searchParams.get("isActive") === "false"
|
||||
? inactiveVendors
|
||||
: activeVendors;
|
||||
await fulfillJson(route, {
|
||||
Data: isCount ? source.slice(0, 1) : source,
|
||||
TotalCount: source.length,
|
||||
PageNumber: 1,
|
||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/vendor-company-roster")) {
|
||||
await fulfillJson(route, {
|
||||
companyId: 101,
|
||||
rowVersion: "rv-visual",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "314-555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
googleMapsUrl: "https://maps.google.com/gateway",
|
||||
notes: "Preferred after-hours vendor",
|
||||
technicians: [
|
||||
{
|
||||
id: 1,
|
||||
contactName: "Adam Whyte",
|
||||
phone: "314-555-0198",
|
||||
email: "dispatch@gateway.test",
|
||||
preferredContact: "Email",
|
||||
tradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
isActive: true,
|
||||
totalJobs: 72,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await mockVendorApi(page, mode);
|
||||
await page.goto("/vendors");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
.MuiDrawer-paper { transform: none !important; }
|
||||
.Toastify__toast-container { display: none !important; }
|
||||
.tsqd-parent-container { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStableScreenshot(page: Page, name: string) {
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||
await expect(page).toHaveScreenshot(name);
|
||||
}
|
||||
|
||||
test.describe("Vendor deterministic pixel regression", () => {
|
||||
test("list", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-list.png");
|
||||
});
|
||||
|
||||
test("filter", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-filter.png");
|
||||
});
|
||||
|
||||
test("add", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-add.png");
|
||||
});
|
||||
|
||||
test("detail", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-detail.png");
|
||||
});
|
||||
|
||||
test("edit", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-edit.png");
|
||||
});
|
||||
|
||||
test("inactive", async ({ page }) => {
|
||||
await openVendorPage(page);
|
||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-inactive.png");
|
||||
});
|
||||
|
||||
test("empty", async ({ page }) => {
|
||||
await openVendorPage(page, "empty");
|
||||
const emptyState = page.getByText("No active companies found");
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-empty.png");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(emptyState).toBeInViewport();
|
||||
});
|
||||
|
||||
test("error", async ({ page }) => {
|
||||
await openVendorPage(page, "error");
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/server error|vendor directory unavailable/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.getByRole("progressbar")).toHaveCount(0);
|
||||
await expectStableScreenshot(page, "vendor-error.png");
|
||||
});
|
||||
|
||||
test("mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openVendorPage(page);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-mobile.png");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
|
||||
});
|
||||
});
|
||||
156
e2e/work-orders/advanced-filters.spec.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-adv-filters-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType: 2,
|
||||
siteCode: woNumber,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate,
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockWorkOrderApis(page: Page, searchUrls: string[]) {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) {
|
||||
return route.fallback();
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname.toLowerCase().includes("problem")) {
|
||||
return fulfillJson(route, { data: [{ id: 1, value: "HVAC", category: "Problem" }] });
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/workorders/board*", (route) => {
|
||||
if (route.request().url().includes("board/search")) {
|
||||
return route.fallback();
|
||||
}
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [boardRow(1, "ADV-WO", "2026-08-19")],
|
||||
});
|
||||
});
|
||||
await page.route("**/api/workorders/board/search*", (route) => {
|
||||
searchUrls.push(route.request().url());
|
||||
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
|
||||
});
|
||||
}
|
||||
|
||||
async function signInAndOpenWorkOrders(page: Page) {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
}
|
||||
|
||||
async function applyAdvancedFilters(page: Page) {
|
||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Advanced Filters" });
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
test.describe("Work Orders advanced filters", () => {
|
||||
test("sends carriedOver on board/search and shows empty copy", async ({ page }) => {
|
||||
const searchUrls: string[] = [];
|
||||
await signInAndOpenWorkOrders(page);
|
||||
await mockWorkOrderApis(page, searchUrls);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const sheet = await applyAdvancedFilters(page);
|
||||
await sheet.getByRole("button", { name: /Indicators/i }).click();
|
||||
await sheet.getByRole("button", { name: /Carried Over ≥ 2/i }).click();
|
||||
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||
|
||||
await expect.poll(() => searchUrls.some((url) => url.includes("carriedOver=true"))).toBe(true);
|
||||
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sends avetaOnly on board/search and shows empty copy", async ({ page }) => {
|
||||
const searchUrls: string[] = [];
|
||||
await signInAndOpenWorkOrders(page);
|
||||
await mockWorkOrderApis(page, searchUrls);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const sheet = await applyAdvancedFilters(page);
|
||||
await sheet.getByRole("button", { name: /Aveta Compliance/i }).click();
|
||||
await sheet.getByRole("checkbox", { name: /Aveta required only/i }).click();
|
||||
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||
|
||||
await expect.poll(() => searchUrls.some((url) => url.includes("avetaOnly=true"))).toBe(true);
|
||||
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sends combined advanced-filter facets on the same board/search request", async ({
|
||||
page,
|
||||
}) => {
|
||||
const searchUrls: string[] = [];
|
||||
await signInAndOpenWorkOrders(page);
|
||||
await mockWorkOrderApis(page, searchUrls);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const sheet = await applyAdvancedFilters(page);
|
||||
await sheet.getByRole("button", { name: /Severity/i }).click();
|
||||
await sheet.getByRole("button", { name: /SEV 1/i }).click();
|
||||
await sheet.getByRole("button", { name: /Indicators/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Add-On$/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Service/i }).click();
|
||||
await sheet.getByText("HVAC").click();
|
||||
await sheet.getByRole("button", { name: /Completion doc/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Pending$/i }).click();
|
||||
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const url = searchUrls[searchUrls.length - 1] ?? "";
|
||||
return (
|
||||
url.includes("severities=1") &&
|
||||
url.includes("addOn=true") &&
|
||||
url.includes("pmTypes=HVAC") &&
|
||||
(url.includes("docStatuses=2") || url.includes("DocStatuses=2"))
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
148
e2e/work-orders/board-day-pagination.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-day-pagination-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function boardRow(id: number, woNumber: string, scheduledDate: string) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType: 2,
|
||||
siteCode: woNumber,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate,
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
|
||||
});
|
||||
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
|
||||
for (const node of nodes) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function mockWorkOrderBoard(page: Page) {
|
||||
const wednesday = Array.from({ length: 60 }, (_, index) =>
|
||||
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
|
||||
);
|
||||
const monday = Array.from({ length: 5 }, (_, index) =>
|
||||
boardRow(2000 + index, `MON-${2000 + index}`, "2026-08-17"),
|
||||
);
|
||||
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/"),
|
||||
async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("workorders/board/search")) {
|
||||
const page = Number(new URL(url).searchParams.get("page") ?? "0");
|
||||
const pageSize = Number(new URL(url).searchParams.get("pageSize") ?? "50");
|
||||
const items = wednesday.slice(page * pageSize, page * pageSize + pageSize);
|
||||
await fulfillJson(route, {
|
||||
items,
|
||||
totalCount: 1778,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes("workorders/board") && route.request().method() === "GET") {
|
||||
const weekStart = new URL(url).searchParams.get("weekStart") ?? "2026-08-17";
|
||||
const scheduled =
|
||||
weekStart === "2026-08-17" ? [...wednesday, ...monday] : monday.slice(0, 1);
|
||||
await fulfillJson(route, {
|
||||
weekStart,
|
||||
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
|
||||
counts: { returned: scheduled.length, total: scheduled.length },
|
||||
unscheduled: [],
|
||||
scheduled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { data: [], items: [] });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("work order per-day pagination (SH-241)", () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => {
|
||||
localStorage.setItem("auth", JSON.stringify(user));
|
||||
const style = document.createElement("style");
|
||||
style.textContent = ".tsqd-parent-container { display: none !important; }";
|
||||
document.documentElement.appendChild(style);
|
||||
}, TEST_USER);
|
||||
await mockWorkOrderBoard(page);
|
||||
});
|
||||
|
||||
test("changes rows-per-page, keeps search pager on advanced filters, and resets on day or week change", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pager = page.getByTestId("wo-day-pagination");
|
||||
await page.goto("/workorders");
|
||||
await hideQueryDevtools(page);
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(pager).toBeVisible();
|
||||
await expect(pager.getByText("Page 1 of 2")).toBeVisible();
|
||||
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
|
||||
await expect(page.getByText("WO-1000").first()).toBeVisible();
|
||||
await expect(page.getByText("WO-1050")).toHaveCount(0);
|
||||
|
||||
await pager.getByLabel("Rows per page").selectOption("25");
|
||||
await expect(pager.getByText("Page 1 of 3")).toBeVisible();
|
||||
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
|
||||
await expect(page.getByText("WO-1025")).toHaveCount(0);
|
||||
|
||||
await hideQueryDevtools(page);
|
||||
await page.getByTestId("wo-day-pagination-next").click();
|
||||
await expect(pager.getByText("Page 2 of 3")).toBeVisible();
|
||||
await expect(page.getByText("WO-1025").first()).toBeVisible();
|
||||
await expect(page.getByText("WO-1000")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true });
|
||||
await expect(pager.getByText("Page 1 of 1")).toBeVisible();
|
||||
await expect(page.getByText("MON-2000").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
|
||||
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
|
||||
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||
await expect(page.getByTestId("wo-day-pagination").getByText("Page 1 of")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||
await page.getByRole("button", { name: /Indicators/i }).click();
|
||||
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click();
|
||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||
await expect(pager).toBeVisible();
|
||||
await expect(pager.getByText("RESULTS · 1778 items")).toBeVisible();
|
||||
await expect(pager.getByText("Page 1 of 36")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Clear all" }).click();
|
||||
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||
});
|
||||
});
|
||||
181
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Completion uploads from a mobile-sized viewport must reach the API as
|
||||
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
|
||||
test.use({ ...devices["Pixel 7"] });
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
const ROW_VERSION = "AAAAAAAAB9E=";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-completion-uploads-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
// Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf).
|
||||
const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1");
|
||||
const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex");
|
||||
const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex");
|
||||
const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex");
|
||||
|
||||
type BoardRowOptions = { id: number; woNumber: string; workOrderType: number };
|
||||
|
||||
function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType,
|
||||
siteCode: "BK5",
|
||||
lifecycleStatusLabel: "In Progress",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
docStatus: "No",
|
||||
rowVersion: ROW_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function multipartBoundary(request: Request): string | undefined {
|
||||
const contentType = request.headers()["content-type"] ?? "";
|
||||
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
|
||||
});
|
||||
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
|
||||
for (const node of nodes) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function mockBoard(page: Page, row: ReturnType<typeof boardRow>) {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) return route.fallback();
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/workorders/board*", (route) => {
|
||||
if (route.request().url().includes("board/search")) return route.fallback();
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [row],
|
||||
});
|
||||
});
|
||||
await page.route("**/api/workorders/board/search*", (route) =>
|
||||
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function openCompletionTab(page: Page, woNumber: string) {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
await page.goto("/workorders");
|
||||
await hideQueryDevtools(page);
|
||||
const row = page.getByRole("row").filter({ hasText: woNumber });
|
||||
await row.getByRole("button", { name: "View details" }).click();
|
||||
const panel = page.getByRole("dialog", { name: "Work order details" });
|
||||
await expect(panel).toBeVisible({ timeout: 15_000 });
|
||||
await panel.getByRole("tab", { name: "Completion Doc" }).click();
|
||||
return panel;
|
||||
}
|
||||
|
||||
test.describe("Completion uploads on a mobile viewport", () => {
|
||||
test("uploads a signed PDF as multipart with the file and work order version", async ({
|
||||
page,
|
||||
}) => {
|
||||
const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 });
|
||||
await mockBoard(page, row);
|
||||
await page.route("**/api/workorders/71/detail", (route) =>
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
|
||||
);
|
||||
let upload: Request | undefined;
|
||||
await page.route("**/api/workorders/71/completion-doc", (route) => {
|
||||
upload = route.request();
|
||||
return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" });
|
||||
});
|
||||
|
||||
const panel = await openCompletionTab(page, row.woNumber);
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await panel.getByRole("button", { name: "Upload signed PDF" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF });
|
||||
|
||||
await expect.poll(() => upload?.method()).toBe("POST");
|
||||
const boundary = multipartBoundary(upload!);
|
||||
expect(boundary, upload!.headers()["content-type"]).toBeTruthy();
|
||||
const body = upload!.postDataBuffer()?.toString("latin1") ?? "";
|
||||
expect(body).toContain(`--${boundary}`);
|
||||
expect(body).toContain('name="file"; filename="Signed Completion.pdf"');
|
||||
expect(body).toContain("%PDF-1.7");
|
||||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
});
|
||||
|
||||
test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => {
|
||||
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
|
||||
const media: { id: number; category: number; url: string; fileName: string }[] = [];
|
||||
await mockBoard(page, row);
|
||||
await page.route("**/api/workorders/72/detail", (route) =>
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }),
|
||||
);
|
||||
const uploads: { contentType: string; body: string }[] = [];
|
||||
await page.route("**/api/workorders/72/media", (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postDataBuffer()?.toString("latin1") ?? "";
|
||||
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
|
||||
const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file";
|
||||
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
|
||||
media.push(item);
|
||||
return fulfillJson(route, item);
|
||||
});
|
||||
let categorize: Request | undefined;
|
||||
await page.route("**/api/workorders/72/media/*", (route) => {
|
||||
categorize = route.request();
|
||||
const item = media[0]!;
|
||||
return fulfillJson(route, { ...item, category: 1 });
|
||||
});
|
||||
|
||||
const panel = await openCompletionTab(page, row.woNumber);
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles([
|
||||
{ name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG },
|
||||
{ name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 },
|
||||
{ name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV },
|
||||
]);
|
||||
|
||||
await expect.poll(() => uploads.length).toBe(3);
|
||||
for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) {
|
||||
expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
expect(uploads[index]!.body).toContain(`filename="${name}"`);
|
||||
}
|
||||
|
||||
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
|
||||
await expect.poll(() => categorize?.method()).toBe("PATCH");
|
||||
const boundary = multipartBoundary(categorize!);
|
||||
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
|
||||
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
|
||||
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
|
||||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
});
|
||||
});
|
||||
80
e2e/work-orders/due-date-schedule-floor.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "due-date-floor-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
test.describe("Due Date floor in the Create WO wizard (SH-356)", () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test("disables days before Schedule On and clears Due Date when Schedule On passes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => {
|
||||
localStorage.setItem("auth", JSON.stringify(user));
|
||||
const style = document.createElement("style");
|
||||
style.textContent = ".tsqd-parent-container { display: none !important; }";
|
||||
document.documentElement.appendChild(style);
|
||||
}, TEST_USER);
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/"),
|
||||
async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await fulfillJson(route, {}, 500);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
data: [],
|
||||
items: [],
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 0, total: 0 },
|
||||
unscheduled: [],
|
||||
scheduled: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: /Pick a date/ }).click();
|
||||
const dateTab = page.getByRole("button", { name: "Date", exact: true });
|
||||
if (await dateTab.isVisible()) {
|
||||
await dateTab.click();
|
||||
}
|
||||
await page.getByRole("button", { name: "20", exact: true }).click();
|
||||
|
||||
await page.getByRole("button", { name: /No due date/ }).click();
|
||||
await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "21", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /Aug 20/ }).click();
|
||||
const dateTabAgain = page.getByRole("button", { name: "Date", exact: true });
|
||||
if (await dateTabAgain.isVisible()) {
|
||||
await dateTabAgain.click();
|
||||
}
|
||||
await page.getByRole("button", { name: "25", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
93
e2e/work-orders/type-filter.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-type-filter-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType: 2,
|
||||
siteCode: woNumber,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate,
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockWorkOrderBoard(page: Page, boardUrls: string[]) {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (pathname.includes("/workorders/board")) {
|
||||
return route.fallback();
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/workorders/board*", (route) => {
|
||||
if (route.request().url().includes("board/search")) {
|
||||
return route.fallback();
|
||||
}
|
||||
boardUrls.push(route.request().url());
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [boardRow(1, "TYPE-WO", "2026-08-19")],
|
||||
});
|
||||
});
|
||||
await page.route("**/api/workorders/board/search*", (route) =>
|
||||
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Work Orders type filter", () => {
|
||||
test("sends union types on the board request and omits them when cleared", async ({ page }) => {
|
||||
const boardUrls: string[] = [];
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
TEST_USER,
|
||||
);
|
||||
await mockWorkOrderBoard(page, boardUrls);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const trigger = page.getByTestId("wo-type-filter-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
const typeMenu = page.getByTestId("wo-type-filter-menu");
|
||||
await expect(typeMenu.getByRole("menuitemcheckbox", { name: "PM" })).toBeVisible();
|
||||
await typeMenu.getByRole("menuitemcheckbox", { name: "PM" }).click();
|
||||
await typeMenu.getByRole("menuitemcheckbox", { name: "Emergency" }).click();
|
||||
await expect(trigger).toContainText("2 types selected");
|
||||
|
||||
await expect
|
||||
.poll(() => boardUrls.some((url) => url.includes("types=2") && url.includes("types=3")))
|
||||
.toBe(true);
|
||||
|
||||
await typeMenu.getByRole("menuitemcheckbox", { name: "All types" }).click();
|
||||
await expect(trigger).toContainText("All types");
|
||||
});
|
||||
});
|
||||
91
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "work-order-vendor-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
test("edits only the vendor assigned to the current work order", async ({ page }) => {
|
||||
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
|
||||
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
Id: 501,
|
||||
InternalWONumber: "WO-501",
|
||||
WorkerOrderNumber: "CUSTOMER-501",
|
||||
WorkerOrderTitle: "Emergency boiler repair",
|
||||
Status: "Dispatched",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [
|
||||
{
|
||||
Id: 801,
|
||||
DispatchNumber: "D-801",
|
||||
VendorId: 7,
|
||||
VendorName: "Gateway Plumbing",
|
||||
VendorEmail: "dispatch@gateway.test",
|
||||
Status: "Sent",
|
||||
DispatchedAt: "2026-07-23T12:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendors/7/work-order-update", async (route) => {
|
||||
updateRequest = {
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON() as Record<string, unknown>,
|
||||
};
|
||||
await fulfillJson(route, { data: { id: 7 } });
|
||||
});
|
||||
await page.route("**/api/vendors/7", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 7,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
PreferredContact: "Email",
|
||||
Phone: "3145550198",
|
||||
Email: "dispatch@gateway.test",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
IsActive: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/workorders/lookups/dispatchers", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||
|
||||
await page.goto("/workorders/501");
|
||||
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit vendor" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
|
||||
await expect(page.getByText("Preferred contact")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
|
||||
await page.getByLabel("Technician name").fill("Taylor Tech");
|
||||
await page.getByLabel("Phone").fill("3145550111");
|
||||
await page.getByLabel("Email").fill("taylor@example.com");
|
||||
await page.getByLabel("Notes").fill("Text before arrival");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
|
||||
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
|
||||
expect(updateRequest?.body).toEqual({
|
||||
workOrderId: 501,
|
||||
contactName: "Taylor Tech",
|
||||
phone: "(314) 555-0111",
|
||||
email: "taylor@example.com",
|
||||
notes: "Text before arrival",
|
||||
});
|
||||
});
|
||||
137
e2e/work-orders/week-day-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-week-day-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
|
||||
return {
|
||||
id,
|
||||
woNumber,
|
||||
workOrderType: 2,
|
||||
siteCode: woNumber,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate,
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockWorkOrderBoard(page: Page) {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (pathname.includes("/workorders/board")) {
|
||||
return route.fallback();
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/workorders/board*", (route) => {
|
||||
if (route.request().url().includes("board/search")) {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const weekStart = url.searchParams.get("weekStart") ?? "2026-08-17";
|
||||
const scheduled =
|
||||
weekStart === "2026-08-17"
|
||||
? [boardRow(1, "MON-WO", "2026-08-17"), boardRow(2, "WED-WO", "2026-08-19")]
|
||||
: [boardRow(4, "PREV-MON-WO", "2026-08-10")];
|
||||
return fulfillJson(route, {
|
||||
weekStart,
|
||||
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
|
||||
counts: { returned: scheduled.length + 1, total: scheduled.length + 1 },
|
||||
unscheduled: [boardRow(3, "UNSCHED-WO", null)],
|
||||
scheduled,
|
||||
});
|
||||
});
|
||||
await page.route("**/api/workorders/board/search*", (route) =>
|
||||
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Work Orders week/day navigation (SH-240)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript(
|
||||
(user) => localStorage.setItem("auth", JSON.stringify(user)),
|
||||
TEST_USER,
|
||||
);
|
||||
await mockWorkOrderBoard(page);
|
||||
});
|
||||
|
||||
test("scopes the table to one day and excludes Unscheduled", async ({ page }) => {
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const dispatcher = page.getByRole("button", { name: "All dispatchers" });
|
||||
await expect(dispatcher).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Week and day").getByRole("button", { name: "All dispatchers" }),
|
||||
).toHaveCount(0);
|
||||
const titleBox = await page.getByRole("heading", { name: "Work Orders" }).boundingBox();
|
||||
const dispatcherBox = await dispatcher.boundingBox();
|
||||
const weekBox = await page.getByRole("button", { name: "Week 8/17–8/21" }).boundingBox();
|
||||
expect(titleBox).toBeTruthy();
|
||||
expect(dispatcherBox).toBeTruthy();
|
||||
expect(weekBox).toBeTruthy();
|
||||
expect(dispatcherBox!.y).toBeGreaterThan(titleBox!.y);
|
||||
expect(weekBox!.y).toBeGreaterThan(dispatcherBox!.y);
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
await expect(page.getByText("WED-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("MON-WO")).toHaveCount(0);
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Unscheduled/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Mon · 08/17" }).click();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByText("MON-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("WED-WO")).toHaveCount(0);
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("updates day tabs and selects Monday when the week changes", async ({ page }) => {
|
||||
await page.goto("/workorders");
|
||||
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Week 8/17–8/21" }).click();
|
||||
await page.getByRole("button", { name: /Aug 10/ }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Week 8/10–8/14" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/10" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("tab", { name: "Fri · 08/14" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveCount(0);
|
||||
await expect(page.getByText("PREV-MON-WO").first()).toBeVisible();
|
||||
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
168
e2e/work-orders/wizard-vendor-company-notes.spec.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wizard-vendor-notes-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const LOCATIONS = [
|
||||
{
|
||||
id: 10,
|
||||
name: "STL-01 · St. Louis, MO",
|
||||
siteCode: "STL-01",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
code: "STL-01",
|
||||
},
|
||||
];
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 101,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
type Captured = { method: string; path: string; body: unknown };
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockApi(page: Page, captured: Captured[]) {
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const { pathname } = new URL(request.url());
|
||||
if (request.method() !== "GET") {
|
||||
captured.push({ method: request.method(), path: pathname, body: request.postDataJSON() });
|
||||
}
|
||||
if (pathname.endsWith("/workorders/board") && request.method() === "POST") {
|
||||
await fulfillJson(route, { data: { id: 900, woNumber: "SH00900" } });
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith("/vendor-company-roster/55") && request.method() === "PATCH") {
|
||||
await fulfillJson(route, {
|
||||
companyId: 55,
|
||||
rowVersion: "rv-2",
|
||||
name: "Gateway Plumbing",
|
||||
notes: "Gate code 4411",
|
||||
technicians: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith("/vendor-company-roster") && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
companyId: 55,
|
||||
rowVersion: "rv-1",
|
||||
name: "Gateway Plumbing",
|
||||
notes: "Call before arrival",
|
||||
technicians: [{ id: 101, contactName: "Adam Whyte", isActive: true }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname.includes("workorders/board")) {
|
||||
await fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 0, total: 0 },
|
||||
unscheduled: [],
|
||||
scheduled: [],
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith("/locations/10")) {
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
id: 10,
|
||||
name: "STL-01 · St. Louis, MO",
|
||||
contact: "Jamie Site",
|
||||
phone: "3145550100",
|
||||
zipCode: "63101",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) {
|
||||
await fulfillJson(route, { data: LOCATIONS });
|
||||
return;
|
||||
}
|
||||
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
|
||||
await fulfillJson(route, { data: VENDORS });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { data: [], items: [] });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("wizard vendor company notes (SH-321)", () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test("pre-fills the company notes and saves edits to the vendor company on create", async ({
|
||||
page,
|
||||
}) => {
|
||||
const captured: Captured[] = [];
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => {
|
||||
localStorage.setItem("auth", JSON.stringify(user));
|
||||
const style = document.createElement("style");
|
||||
style.textContent = ".tsqd-parent-container { display: none !important; }";
|
||||
document.documentElement.appendChild(style);
|
||||
}, TEST_USER);
|
||||
await mockApi(page, captured);
|
||||
|
||||
await page.goto("/workorders");
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /Select site/ }).click();
|
||||
await page.getByRole("button", { name: "STL-01 · St. Louis, MO" }).first().click();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /Select technician/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
.last()
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const notes = page.getByPlaceholder("Contact preferences, availability, etc.");
|
||||
await expect(notes).toHaveValue("Call before arrival");
|
||||
await notes.fill("Gate code 4411");
|
||||
|
||||
await page.getByRole("button", { name: /Create/ }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => captured.find((c) => c.method === "PATCH"))
|
||||
.toEqual({
|
||||
method: "PATCH",
|
||||
path: "/api/vendor-company-roster/55",
|
||||
body: {
|
||||
rowVersion: "rv-1",
|
||||
addTechnicians: [],
|
||||
companyFields: { notes: "Gate code 4411" },
|
||||
},
|
||||
});
|
||||
expect(captured.some((c) => c.method === "POST" && c.path.endsWith("/workorders/board"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
121
e2e/work-orders/wo-number-sh-placeholder.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-number-sh-placeholder-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
|
||||
});
|
||||
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
|
||||
for (const node of nodes) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const REAL_ROW = {
|
||||
id: 1000,
|
||||
woNumber: "12345678901",
|
||||
workOrderType: 2,
|
||||
siteCode: "DAL1",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
};
|
||||
|
||||
async function mockBoard(page: Page, patches: unknown[]) {
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
if (request.method() !== "GET") {
|
||||
patches.push({ url, body: request.postDataJSON() });
|
||||
await fulfillJson(route, {}, 500);
|
||||
return;
|
||||
}
|
||||
if (url.includes("workorders/board/search")) {
|
||||
await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 });
|
||||
return;
|
||||
}
|
||||
if (url.includes("workorders/board")) {
|
||||
await fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [REAL_ROW],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { data: [], items: [] });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("inline WO number SH placeholder", () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => {
|
||||
localStorage.setItem("auth", JSON.stringify(user));
|
||||
}, TEST_USER);
|
||||
});
|
||||
|
||||
test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mutations: unknown[] = [];
|
||||
await mockBoard(page, mutations);
|
||||
await page.goto("/workorders");
|
||||
await hideQueryDevtools(page);
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("12345678901").first()).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Add WO/ })
|
||||
.first()
|
||||
.click();
|
||||
await page
|
||||
.getByText(/^Insert WO #/)
|
||||
.first()
|
||||
.click();
|
||||
const draftInput = page.getByLabel("Work order number");
|
||||
await draftInput.fill("SH00001");
|
||||
await draftInput.press("Enter");
|
||||
await expect(draftInput).toHaveCount(0);
|
||||
await expect(page.getByText("SH00001", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/must be 11 digits/)).toHaveCount(0);
|
||||
|
||||
await page.getByText("12345678901", { exact: true }).first().click();
|
||||
const savedInput = page.getByLabel("Work order number");
|
||||
await savedInput.fill("SH00009");
|
||||
await savedInput.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Cannot replace an APM work order number with an internal number"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible();
|
||||
expect(mutations).toEqual([]);
|
||||
});
|
||||
});
|
||||
289
e2e/work-orders/work-orders.visual.spec.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
const WEEK_START = "2026-08-17";
|
||||
const WEEK_END = "2026-08-21";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-visual-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order Visual Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const DISPATCHERS = [{ id: "1", name: "Pat Dispatcher" }];
|
||||
|
||||
const LOCATIONS = [
|
||||
{
|
||||
id: 10,
|
||||
name: "STL-01 · St. Louis, MO",
|
||||
siteCode: "STL-01",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
code: "STL-01",
|
||||
},
|
||||
];
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 101,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
const scheduledRow = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
workOrderType: 2,
|
||||
siteCode: "STL-01",
|
||||
locationName: "St. Louis HQ",
|
||||
locationId: 10,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
vendorId: 101,
|
||||
vendorName: "Gateway Plumbing",
|
||||
techName: "Adam Whyte",
|
||||
title: "Quarterly HVAC PM",
|
||||
dueDate: "2026-08-21",
|
||||
rowVersion: "rv-visual",
|
||||
};
|
||||
|
||||
const unassignedRow = {
|
||||
...scheduledRow,
|
||||
id: 2,
|
||||
woNumber: "WO-502",
|
||||
scheduledDate: null,
|
||||
lifecycleStatusLabel: "Pending",
|
||||
title: "Boiler leak follow-up",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function isBoardList(pathname: string) {
|
||||
return pathname.endsWith("/workorders/board") || pathname.endsWith("/api/workorders/board");
|
||||
}
|
||||
|
||||
function isBoardSearch(pathname: string) {
|
||||
return pathname.includes("/workorders/board/search");
|
||||
}
|
||||
|
||||
function isBoardDetail(pathname: string) {
|
||||
return /\/workorders\/\d+\/detail$/.test(pathname);
|
||||
}
|
||||
|
||||
function isAccountList(pathname: string) {
|
||||
return pathname.includes("/Account/GetAccountList");
|
||||
}
|
||||
|
||||
async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await page.clock.setFixedTime(new Date(FROZEN_NOW));
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardSearch(pathname)) {
|
||||
const items = mode === "default" ? [unassignedRow] : [];
|
||||
await fulfillJson(route, { items, totalCount: items.length, page: 0, pageSize: 50 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardList(pathname)) {
|
||||
if (mode === "error") {
|
||||
await fulfillJson(route, { message: "Work order board unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
const scheduled = mode === "empty" ? [] : [scheduledRow];
|
||||
const unscheduled = mode === "empty" ? [] : [unassignedRow];
|
||||
await fulfillJson(route, {
|
||||
weekStart: WEEK_START,
|
||||
weekEnd: WEEK_END,
|
||||
counts: { returned: scheduled.length + unscheduled.length, total: scheduled.length + 1 },
|
||||
unscheduled,
|
||||
scheduled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardDetail(pathname)) {
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
info: scheduledRow,
|
||||
comments: [],
|
||||
audit: [],
|
||||
media: [],
|
||||
completion: {},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/workorders/lookups/dispatchers")) {
|
||||
await fulfillJson(route, { data: DISPATCHERS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/Contact/GetLocations") || pathname.includes("/locations/sites")) {
|
||||
await fulfillJson(route, { data: LOCATIONS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/vendors/Dropdown") || pathname.endsWith("/vendors/dropdown")) {
|
||||
await fulfillJson(route, { data: VENDORS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/DropdownOptions/ByCategory/Problem")) {
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "HVAC", category: "Problem" },
|
||||
{ id: 2, value: "Plumbing", category: "Problem" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/api/User")) {
|
||||
await fulfillJson(route, { data: DISPATCHERS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAccountList(pathname)) {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Amazon",
|
||||
number: "AMZ-1",
|
||||
industry: "Retail",
|
||||
website: "",
|
||||
primaryContact: "",
|
||||
phoneNumber: "",
|
||||
owner: "",
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await mockWorkOrderApi(page, mode);
|
||||
await page.goto("/workorders");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
.MuiDrawer-paper { transform: none !important; }
|
||||
.Toastify__toast-container { display: none !important; }
|
||||
.tsqd-parent-container { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectWorkOrderPageReady(page: Page) {
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStableScreenshot(page: Page, name: string) {
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||
await expect(page).toHaveScreenshot(name);
|
||||
}
|
||||
|
||||
test.describe("Work Orders deterministic pixel regression", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("list", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await expectWorkOrderPageReady(page);
|
||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-list.png");
|
||||
});
|
||||
|
||||
test("filters", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await expectWorkOrderPageReady(page);
|
||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-filters.png");
|
||||
});
|
||||
|
||||
test("new", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await expectWorkOrderPageReady(page);
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-new.png");
|
||||
});
|
||||
|
||||
test("detail", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await expectWorkOrderPageReady(page);
|
||||
const row = page.locator("#wo-row-1");
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "View details" }).click();
|
||||
await expect(page.getByRole("heading", { name: "#WO-501" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-detail.png");
|
||||
});
|
||||
|
||||
test("empty", async ({ page }) => {
|
||||
await openWorkOrderPage(page, "empty");
|
||||
await expectWorkOrderPageReady(page);
|
||||
await page.getByLabel("Search work orders").fill("zz");
|
||||
await expect(page.getByText("No work orders match your search")).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-empty.png");
|
||||
});
|
||||
|
||||
test("error", async ({ page }) => {
|
||||
await openWorkOrderPage(page, "error");
|
||||
await expectWorkOrderPageReady(page);
|
||||
const alert = page.getByRole("main").getByRole("alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText(/server error/i);
|
||||
await expectStableScreenshot(page, "wo-error.png");
|
||||
});
|
||||
|
||||
test("mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openWorkOrderPage(page);
|
||||
await expectWorkOrderPageReady(page);
|
||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-mobile.png");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-mobile-navigation.png");
|
||||
});
|
||||
});
|
||||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
|
||||
* boolean.
|
||||
*
|
||||
* `{value && <Element />}` renders its left operand when it is falsy and
|
||||
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
|
||||
* must be `boolean` (or boolean literals) across the whole union. The rule
|
||||
* asks the TypeScript checker for the type of the left operand and reports
|
||||
* unless every union constituent is boolean-like.
|
||||
*
|
||||
* Type-aware by design: a selector that cannot see operand types would give
|
||||
* false confidence rather than enforcement. If type services are unexpectedly
|
||||
* unavailable in a governed TSX file, the rule fails closed (reports) instead
|
||||
* of silently claiming the expression is safe.
|
||||
*/
|
||||
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
|
||||
|
||||
function isBooleanLikeType(type) {
|
||||
return (type.flags & booleanLikeFlags) !== 0;
|
||||
}
|
||||
|
||||
function isEntirelyBoolean(type) {
|
||||
if (type.isUnion()) {
|
||||
return type.types.every((constituent) => isBooleanLikeType(constituent));
|
||||
}
|
||||
return isBooleanLikeType(type);
|
||||
}
|
||||
|
||||
const transparentExpressionTypes = new Set([
|
||||
"ChainExpression",
|
||||
"ParenthesizedExpression",
|
||||
"TSAsExpression",
|
||||
"TSNonNullExpression",
|
||||
"TSTypeAssertion",
|
||||
]);
|
||||
|
||||
function isInRenderedPosition(node) {
|
||||
let current = node;
|
||||
while (current.parent) {
|
||||
const parent = current.parent;
|
||||
if (parent.type === "JSXExpressionContainer") {
|
||||
return parent.parent?.type !== "JSXAttribute";
|
||||
}
|
||||
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the left operand of `&&` in JSX to be entirely boolean",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonBooleanLeft:
|
||||
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
|
||||
typeServicesUnavailable:
|
||||
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
||||
|
||||
return {
|
||||
LogicalExpression(node) {
|
||||
if (node.operator !== "&&") {
|
||||
return;
|
||||
}
|
||||
if (!isInRenderedPosition(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!services || services.program === null) {
|
||||
context.report({ node, messageId: "typeServicesUnavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
const leftType = services.getTypeAtLocation(node.left);
|
||||
if (!isEntirelyBoolean(leftType)) {
|
||||
context.report({ node: node.left, messageId: "nonBooleanLeft" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
55
eslint-rules/no-vp-error-outside-text.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
function containsVpErrorToken(node) {
|
||||
if (!node || typeof node !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type === "Literal" && typeof node.value === "string") {
|
||||
return node.value.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
if (node.type === "TemplateElement") {
|
||||
return node.value.raw.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
return Object.entries(node).some(([key, value]) => {
|
||||
if (key === "parent") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsVpErrorToken);
|
||||
}
|
||||
return containsVpErrorToken(value);
|
||||
});
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the Text component for legacy vp-error styling",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useText:
|
||||
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementName = node.parent?.name;
|
||||
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({ node, messageId: "useText" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
90
eslint.config.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import js from "@eslint/js";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
||||
import pluginReactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
|
||||
import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
|
||||
|
||||
const localRules = {
|
||||
rules: {
|
||||
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
|
||||
"no-vp-error-outside-text": noVpErrorOutsideText,
|
||||
},
|
||||
};
|
||||
|
||||
const legacyIgnores = [
|
||||
"src/pages/**",
|
||||
"src/app/store.js",
|
||||
"src/app/slices/**",
|
||||
"src/lib/queryClient.js",
|
||||
"src/hooks/**/*.js",
|
||||
"src/components/*.{js,css}",
|
||||
"src/constants/**",
|
||||
"src/layouts/**",
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: [
|
||||
"src/{api,domain,app,lib,providers,infra,config,hooks,components/auth}/**/*.{ts,tsx}",
|
||||
"src/**/*.{ts,tsx}",
|
||||
"vite.config.ts",
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": pluginReactHooks,
|
||||
"react-refresh": pluginReactRefresh,
|
||||
seahaven: localRules,
|
||||
},
|
||||
rules: {
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"seahaven/no-non-boolean-jsx-and": "error",
|
||||
"seahaven/no-vp-error-outside-text": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
|
||||
message:
|
||||
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
|
||||
message:
|
||||
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["vite.config.ts", "scripts/**/*.{js,mjs}"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
);
|
||||
28
index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1c75bc" />
|
||||
<meta name="description" content="Sea Haven - Work Orders" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||
integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
|
||||
<title>Sea Haven - Work Orders</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30607
package-lock.json
generated
148
package.json
|
|
@ -2,55 +2,115 @@
|
|||
"name": "seahaven-new-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||
"test:github-workflows": "bash scripts/check-github-workflows.sh",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"governance": "node scripts/governance-check.mjs",
|
||||
"verify": "npm run format:check && npm run lint && npm run build && npm test && npm run governance",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/{api,domain,app,lib,providers,infra,config,hooks,components}/**/*.{ts,tsx}": [
|
||||
"eslint --fix --max-warnings=0",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{ts,tsx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"src/test/**/*.{test,integration.test}.{ts,tsx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,md,mdc}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"eslint.config.js": [
|
||||
"prettier --write"
|
||||
],
|
||||
"commitlint.config.js": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
"@fullcalendar/interaction": "^6.1.21",
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@generouted/react-router": "^1.20.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.16.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^2.0.2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.2.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"prettier": "^3.9.5",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
"packageManager": "npm@11.16.0"
|
||||
}
|
||||
|
|
|
|||
32
playwright.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/*.visual.spec.ts",
|
||||
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: {
|
||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||
baseURL: "http://localhost:4173",
|
||||
// Pin the browser timezone to UTC so date-to-ISO assertions in the default
|
||||
// create flow are deterministic regardless of host locale. DST-transition
|
||||
// scenarios override this with their own non-UTC context.
|
||||
timezoneId: "UTC",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev -- --port 4173 --strictPort",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
43
playwright.visual.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.visual.spec.ts",
|
||||
fullyParallel: false,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
|
||||
outputDir: "test-results/visual",
|
||||
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
// Keep the changed-pixel budget strict while ignoring sub-perceptual
|
||||
// antialiasing differences between local and hosted container runtimes.
|
||||
maxDiffPixels: 5,
|
||||
threshold: 0.1,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: "http://localhost:4174",
|
||||
colorScheme: "light",
|
||||
deviceScaleFactor: 1,
|
||||
locale: "en-US",
|
||||
launchOptions: {
|
||||
args: ["--disable-gpu", "--font-render-hinting=none"],
|
||||
},
|
||||
reducedMotion: "reduce",
|
||||
timezoneId: "UTC",
|
||||
trace: "retain-on-failure",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --port 4174 --strictPort",
|
||||
url: "http://localhost:4174",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
|
@ -1,47 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Seahaven - Officetrax</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.3 KiB |
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "Work Orders",
|
||||
"name": "Sea Haven - Work Orders",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"sizes": "64x64 32x32 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
|
|
@ -20,6 +20,6 @@
|
|||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"theme_color": "#1c75bc",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
|
|
|||
108
public/seahaven-logo.svg
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Seahaven Industries — official client wordmark. viewBox cropped to the
|
||||
artwork bounds (original art sits in a middle band of a 100x100 canvas);
|
||||
aspect ratio preserved, nothing distorted. -->
|
||||
<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="9 40 81 22" xml:space="preserve">
|
||||
<title>Seahaven Industries</title>
|
||||
<path fill="#A8D6EF" d="M6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1z"/>
|
||||
<g>
|
||||
<path fill="#2BABE2" d="M19.4,43.8c-0.5-0.3-1-0.5-1.6-0.6c-0.6-0.1-1.2-0.2-1.8,0c-0.1,0-0.2,0.1-0.3,0.1
|
||||
c-0.1,0.1-0.2,0.2-0.2,0.3c0,0.2,0.1,0.3,0.2,0.3c0.3,0.1,0.5,0.1,0.8,0.2c0.6,0.1,1.2,0.2,1.7,0.3c0.5,0.1,1,0.3,1.5,0.7
|
||||
c0.5,0.4,0.8,0.9,0.8,1.6c0.1,1.5-0.5,2.6-1.8,3.4c-0.6,0.3-1.2,0.5-1.9,0.6c-0.8,0.1-1.6,0.1-2.4-0.1c-0.8-0.2-1.6-0.5-2.3-1
|
||||
c-0.1-0.1-0.3-0.2-0.4-0.3c0.1-0.3,0.3-0.5,0.4-0.7c0.1-0.3,0.3-0.5,0.4-0.8c0.1-0.2,0.3-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
|
||||
c1.1,0.7,2.2,1,3.5,0.9c0.2,0,0.4-0.1,0.6-0.2c0.1,0,0.2-0.1,0.2-0.2c0.2-0.2,0.1-0.4-0.1-0.6c-0.2-0.1-0.4-0.1-0.6-0.2
|
||||
c-0.5-0.1-0.9-0.2-1.4-0.2c-0.5-0.1-0.9-0.2-1.3-0.4c-0.3-0.1-0.6-0.3-0.8-0.5c-0.6-0.5-0.8-1.1-0.8-1.9c0.1-1.3,0.7-2.2,1.8-2.8
|
||||
c0.5-0.3,1-0.5,1.6-0.5c1.1-0.2,2.2-0.1,3.2,0.2c0.4,0.1,0.7,0.3,1,0.5c0.2,0.1,0.3,0.2,0.5,0.4C20.2,42.4,19.8,43.1,19.4,43.8z"/>
|
||||
<g>
|
||||
<path fill="#2A2627" d="M84.1,54.2c0.4,0.3,0.7,0.4,1.2,0.3c0.1,0,0.3,0,0.3-0.2c-0.1-0.1-0.2-0.1-0.3-0.2c-0.2,0-0.3-0.1-0.5-0.1
|
||||
c-0.1,0-0.2-0.1-0.3-0.1c-0.5-0.2-0.7-0.7-0.4-1.2c0.1-0.3,0.3-0.4,0.6-0.5c0.6-0.2,1.1-0.2,1.7,0.1c0,0,0.1,0,0.1,0.1
|
||||
c0,0,0,0,0.1,0.1c0,0.1-0.1,0.2-0.2,0.3c-0.1,0.1-0.1,0.2-0.2,0.3C85.8,53,85.4,52.9,85,53c0,0-0.1,0.1-0.1,0.1
|
||||
c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.7,0.5,1.3c-0.1,0.2-0.3,0.4-0.5,0.5c-0.7,0.3-1.5,0.2-2.1-0.3
|
||||
c0.1-0.1,0.1-0.2,0.2-0.4C84,54.5,84.1,54.4,84.1,54.2z"/>
|
||||
<path fill="#2A2627" d="M82.6,52.3c0,0.2,0,0.4-0.1,0.6c0,0.1,0,0.1-0.1,0.2c-0.4,0-0.7,0-1.1,0c0,0.1,0,0.2-0.1,0.3
|
||||
c0.3,0,0.6,0,0.9,0c0.1,0.3-0.1,0.5-0.1,0.7c-0.3,0-0.6,0-0.9,0c0,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.1,0,0.2c0,0,0.1,0,0.1,0
|
||||
c0.2,0,0.5,0,0.7,0c0.1,0,0.2,0,0.3,0c0.1,0.2,0,0.3,0,0.4c0,0.1,0,0.2-0.1,0.4c-0.2,0-0.4,0-0.7,0c-0.2,0-0.4,0-0.7,0
|
||||
c-0.2,0-0.4,0-0.7,0c0-0.5,0.1-1,0.2-1.5c0.1-0.5,0.1-1,0.2-1.5C80.9,52.2,82.3,52.2,82.6,52.3z"/>
|
||||
<g>
|
||||
<path fill="#252122" d="M78.6,40.8c0.9,0,1.8,0,2.8,0c1,1.7,2,3.4,3,5.2c0-0.1,0.1-0.1,0.1-0.2c0.2-1.5,0.4-3,0.5-4.4
|
||||
c0-0.2,0-0.4,0.1-0.6c0.4-0.1,2.4,0,2.7,0c0,0.3-1,8.8-1.2,9.6c-0.4,0.1-0.9,0-1.4,0c-0.5,0-0.9,0-1.4,0c-0.5-0.9-1-1.7-1.5-2.6
|
||||
c-0.5-0.9-1-1.7-1.5-2.6c-0.1,0.2-0.1,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5
|
||||
c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3,0,0.5c0,0.2,0,0.3-0.1,0.5
|
||||
c0,0.2,0,0.3-0.1,0.5c-0.4,0.1-2.4,0.1-2.7,0C77.4,50.1,78.5,41.5,78.6,40.8z"/>
|
||||
<path fill="#252122" d="M39.5,50.5c0-0.1,0-0.2,0-0.3c0.4-3.1,0.8-6.2,1.1-9.3c0.2,0,0.5,0,0.7,0c0.2,0,0.5,0,0.7,0
|
||||
c0.2,0,0.4,0,0.7,0c0.2,0,0.4,0,0.7,0c0,0.6-0.1,1.2-0.2,1.8c-0.1,0.6-0.1,1.2-0.2,1.7c0,0,0,0,0,0c0,0,0,0,0,0c1,0,2,0,3,0
|
||||
c0,0,0.1,0,0.1,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0,0,0,0c0,0,0,0,0,0c0.1,0,0.2,0,0.4,0c0.8,0,1.5,0,2.3,0c0,0.1,0,0.2,0,0.2
|
||||
c-0.1,0.8-0.2,1.6-0.3,2.4c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.7-0.2,1.5-0.3,2.2c0,0.1,0,0.2,0,0.2
|
||||
c-0.3,0.1-2.4,0.1-2.8,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0-0.1,0-0.1c0,0,0,0,0,0c-0.9,0-1.8,0-2.7,0c-0.1,0-0.2,0-0.3,0
|
||||
c0,0,0,0-0.1,0c-0.1,1.2-0.3,2.3-0.4,3.5C41.4,50.5,40.5,50.5,39.5,50.5z"/>
|
||||
<path fill="#252122" d="M54.2,40.8c0.9,0,1.7,0,2.6,0c0.2,0.4,2.8,9.3,2.8,9.6c0,0,0,0,0,0c0,0,0,0,0,0c-0.8,0-1.7,0-2.5,0
|
||||
c-0.1,0-0.1,0-0.2,0c-0.2-0.5-0.3-1-0.4-1.5c-0.3-0.1-2.8-0.1-3.8,0c-0.3,0.5-0.5,1-0.8,1.6c-0.3,0.1-2.4,0.1-2.8,0c0,0,0,0,0,0
|
||||
c0,0,0,0,0,0c1.7-3.2,3.4-6.3,5.1-9.5C54.1,40.9,54.1,40.8,54.2,40.8z M55.1,44.1C55.1,44.1,55,44.1,55.1,44.1
|
||||
c-0.5,0.9-0.9,1.7-1.4,2.5c0,0,0,0.1,0,0.1c0.2,0,0.4,0,0.7,0c0.2,0,0.5,0,0.7,0c0.2,0,0.4,0,0.7,0C55.6,45.8,55.3,45,55.1,44.1z
|
||||
"/>
|
||||
<path fill="#2BABE2" d="M33.4,40.8c0.9,0,1.7,0,2.6,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0.1,0.1,0.2,0.1,0.3c0.9,3.1,1.8,6.1,2.7,9.2
|
||||
c0,0.1-0.1,0.1-0.2,0.1c-0.8,0-1.6,0-2.5,0c0,0-0.1,0-0.1,0c-0.2-0.5-0.3-1.1-0.5-1.6c-0.3-0.1-3.3-0.1-3.8,0
|
||||
c-0.3,0.5-0.5,1-0.8,1.5c-0.3,0.1-2.2,0.1-2.7,0c-0.1-0.1,0-0.2,0.1-0.2c0.5-1,1.1-2,1.6-3c0.9-1.7,1.9-3.5,2.8-5.2
|
||||
C32.9,41.6,33.1,41.2,33.4,40.8z M35,46.7c-0.2-0.8-0.4-1.6-0.6-2.4c0-0.1-0.1-0.1-0.1-0.2c0,0-0.1,0.1-0.1,0.1
|
||||
c-0.4,0.8-0.8,1.6-1.3,2.5c0,0,0,0,0,0.1C33.3,46.8,34.7,46.8,35,46.7z"/>
|
||||
<path fill="#252122" d="M64.9,50.5c-0.9,0-1.8,0-2.7,0c0,0,0,0,0,0c0,0,0,0,0,0c-0.9-3.2-1.8-6.3-2.7-9.5c0,0,0-0.1,0-0.1
|
||||
c0.3-0.1,2.8-0.1,3.2,0c0.5,1.9,0.9,3.8,1.4,5.7c0.3-0.4,0.4-0.8,0.6-1.2c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.7,0.5-1.1
|
||||
c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.8,0.6-1.1c1.1,0,2.1,0,3.2,0c0,0.1-0.1,0.2-0.1,0.4c-1.1,2-2.1,4-3.2,6.1
|
||||
c-0.5,1-1,1.9-1.5,2.9C65.1,50.2,65,50.3,64.9,50.5z"/>
|
||||
<path fill="#252122" d="M76,50.4c-0.3,0.1-6,0.1-6.5,0c0-0.1,0-0.1,0-0.2c0.1-1,0.2-1.9,0.4-2.9c0.1-1,0.2-1.9,0.3-2.9
|
||||
c0.1-1,0.2-1.9,0.4-2.9c0-0.3,0.1-0.5,0.1-0.8c0,0,0,0,0.1,0c0,0,0,0,0,0c2,0,4,0,6,0c0.1,0,0.2,0,0.3,0c0,0,0,0,0.1,0
|
||||
c0,0.4-0.1,0.8-0.1,1.2c0,0.4-0.1,0.8-0.2,1.2c-0.1,0-0.2,0-0.3,0c-1,0-2,0-3,0c-0.1,0-0.3,0-0.4,0c0,0.1,0,0.2-0.1,0.3
|
||||
c0,0.2-0.1,0.5-0.1,0.7c0.1,0.1,0.2,0.1,0.3,0.1c0.8,0,1.5,0,2.3,0c0.1,0,0.3,0,0.4,0c0,0.3-0.2,1.8-0.3,2.3c-0.1,0-0.2,0-0.3,0
|
||||
c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0c-0.1,0.3-0.2,0.8-0.2,1.1c0.6,0,1.2,0,1.8,0c0.6,0,1.2,0,1.8,0c0,0,0,0.1,0,0.1
|
||||
C76.2,48.8,76.1,49.6,76,50.4z"/>
|
||||
<path fill="#2BABE2" d="M28.8,40.8C28.9,40.8,28.9,40.9,28.8,40.8c0,0.8-0.1,1.5-0.2,2.2c0,0.1,0,0.2-0.1,0.3
|
||||
c-0.3,0.1-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0c-0.1,0.4-0.1,0.7-0.1,1c0.1,0.1,0.2,0.1,0.3,0.1
|
||||
c0.7,0,1.5,0,2.2,0c0.1,0,0.3,0,0.4,0c0,0.3-0.1,1.7-0.3,2.3c-0.1,0-0.2,0-0.3,0c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0
|
||||
c-0.1,0.4-0.1,0.7-0.1,1.1c0.3,0.1,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0,0.3-0.2,2-0.3,2.5
|
||||
c0,0-0.1,0-0.1,0c-2.1,0-4.1,0-6.2,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0.4-3.2,0.8-6.4,1.2-9.6c0.1,0,0.2,0,0.3,0
|
||||
c2,0,4,0,5.9,0C28.7,40.8,28.8,40.8,28.8,40.8z"/>
|
||||
<path fill="#282425" d="M52,55.3c0.1-1,0.2-2,0.4-3c0.3,0,0.6,0,0.9,0c0.4,0.5,0.6,1,1,1.6c0.1-0.1,0.1-0.2,0.1-0.3
|
||||
c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0.2,0,0.4,0,0.6,0c0.1,0,0.1,0,0.2,0
|
||||
c0,0.3-0.2,1.9-0.3,3c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0c-0.3-0.5-0.6-1-0.9-1.6c-0.1,0.5-0.1,1-0.2,1.5
|
||||
C52.6,55.3,52.3,55.3,52,55.3z"/>
|
||||
<path fill="#282425" d="M56.6,55.3c0.1-1,0.2-2,0.4-3c0.1,0,0.2,0,0.2,0c0.3,0,0.7,0,1,0c0.2,0,0.4,0,0.7,0.1
|
||||
c0.5,0.2,0.7,0.5,0.8,1c0.1,1-0.6,1.9-1.6,2C57.6,55.3,57.1,55.3,56.6,55.3z M57.6,54.5c0.1,0,0.2,0,0.3,0c0.4,0,0.6-0.2,0.8-0.5
|
||||
c0.2-0.4-0.1-0.9-0.6-0.9c-0.1,0-0.2,0-0.4,0C57.7,53.5,57.7,54,57.6,54.5z"/>
|
||||
<path fill="#282425" d="M73.8,52.2c0,0,0.1,0,0.1,0c0.4,0,0.8,0,1.1,0c0.1,0,0.3,0,0.4,0c0.5,0.1,0.7,0.5,0.7,0.9
|
||||
c0,0.4-0.2,0.7-0.5,1c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.3,0.4,0.6,0.6,1c-0.3,0.1-0.7,0-1,0c-0.2-0.3-0.3-0.7-0.6-1
|
||||
c-0.1,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.2,0,0.4c0,0.1,0,0.3-0.1,0.4c-0.3,0-0.6,0-0.8,0C73.4,54.9,73.7,52.6,73.8,52.2z M74.4,53.6
|
||||
c0.2,0,0.3,0,0.5,0c0.1,0,0.2-0.1,0.2-0.1c0.1-0.1,0.1-0.2,0.1-0.4c0-0.1-0.1-0.2-0.2-0.2c-0.2,0-0.3,0-0.5,0
|
||||
C74.5,53.2,74.5,53.4,74.4,53.6z"/>
|
||||
<path fill="#292627" d="M61.3,52.2c0.3,0,0.6,0,0.9,0c0.1,0.2,0,0.3,0,0.4c-0.1,0.4-0.1,0.9-0.2,1.3c0,0.1,0,0.2,0,0.2
|
||||
c0,0.3,0.3,0.4,0.6,0.3c0.2-0.1,0.4-0.2,0.4-0.5c0-0.2,0.1-0.4,0.1-0.5c0.1-0.4,0.1-0.8,0.2-1.3c0.2,0,0.3,0,0.5,0
|
||||
c0.1,0,0.3,0,0.4,0c0,0.1,0,0.1,0,0.1c-0.1,0.6-0.1,1.1-0.2,1.7c-0.1,0.9-0.8,1.3-1.5,1.3c-0.1,0-0.2,0-0.4,0
|
||||
c-0.7-0.1-1.1-0.6-1.1-1.3c0-0.5,0.1-0.9,0.2-1.4C61.3,52.5,61.3,52.4,61.3,52.2z"/>
|
||||
<path fill="#2A2627" d="M66.6,53.1c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.8,0.5,1.3
|
||||
c-0.1,0.3-0.3,0.5-0.6,0.5c-0.7,0.2-1.3,0.2-2-0.2c0,0-0.1-0.1-0.1-0.1c0.1-0.2,0.2-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
|
||||
c0.3,0.1,0.5,0.2,0.8,0.2c0.1,0,0.1,0,0.2,0c0.1,0,0.1-0.1,0.1-0.1c0-0.1,0-0.1-0.1-0.1c-0.1,0-0.3-0.1-0.4-0.1
|
||||
c-0.2-0.1-0.4-0.1-0.6-0.2c-0.4-0.2-0.6-0.6-0.4-1.1c0.1-0.3,0.4-0.5,0.7-0.7c0.5-0.2,1-0.2,1.5,0c0.1,0,0.1,0.1,0.2,0.1
|
||||
c0,0,0.1,0.1,0.1,0.1c-0.1,0.2-0.2,0.4-0.4,0.6c-0.1,0-0.1,0-0.2-0.1c-0.3-0.1-0.5-0.1-0.8-0.1C66.7,53,66.6,53,66.6,53.1z"/>
|
||||
<path fill="#292626" d="M72.2,52.3c0,0.2,0,0.5-0.1,0.8c-0.2,0.1-0.5,0-0.8,0c-0.1,0.4-0.1,0.7-0.2,1.1c-0.1,0.4-0.1,0.7-0.1,1.1
|
||||
c-0.3,0-0.6,0-0.9,0c0-0.4,0.1-0.7,0.1-1.1c0-0.4,0.1-0.7,0.1-1.1c-0.3-0.1-0.5,0-0.7,0c-0.1-0.3,0-0.5,0.1-0.8
|
||||
C70,52.2,71.8,52.2,72.2,52.3z"/>
|
||||
<path fill="#292627" d="M49.9,52.2c0.3,0,0.5,0,0.8,0c0,0.5-0.1,1-0.2,1.5c0,0.5-0.1,1-0.2,1.5c-0.3,0-0.6,0-0.8,0
|
||||
C49.5,55,49.7,52.8,49.9,52.2z"/>
|
||||
<path fill="#292526" d="M78,52.2c0.3,0,0.6,0,0.8,0c0,0.1,0,0.1,0,0.2c-0.1,0.6-0.1,1.1-0.2,1.7c0,0.4-0.1,0.7-0.1,1.1
|
||||
c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.5,0C77.7,54.3,77.8,53.3,78,52.2z"/>
|
||||
<g>
|
||||
<path fill="#60B3E3" d="M42.6,55.2c-0.9,0.1-2,0.1-3.4,0.5c-0.7,0.2-2,0.4-3.5,1.1c-0.7,0.3-0.5,0.3-2.2,1.2
|
||||
c-1.2,0.6-2,1.1-3.1,1.5c-1.3,0.6-2,0.9-2.8,1c-0.8,0.2-2.1,0.3-3.5,0c-0.5-0.1-1.1-0.2-1.9-0.6c-0.9-0.5-1.5-1.1-1.8-1.5
|
||||
c0.2,0,0.4,0,0.7,0c1-0.1,2.3-0.1,3.7-0.6c0.4-0.1,0.8-0.3,1.6-0.6c0.1,0,0.8-0.3,1.6-0.7c1.9-0.9,3.9-1.9,4-2
|
||||
c0,0,3.2-1.7,5-2.6c0.2-0.1,0.6-0.3,1.2-0.3c0.3,0,0.6,0,0.9,0c0.6,0,1.4,0.1,2.4,0.3c1.2,0.3,2,0.7,2.4,0.9
|
||||
c0.2,0.1,1,0.6,2,1.4c0.5,0.4,0.8,0.7,1.1,1C45.3,55.1,43.7,55.1,42.6,55.2z"/>
|
||||
<path fill="#2BABE2" d="M11.5,55c1.1-0.3,1.5-0.3,1.5-0.3c0.5-0.1,0.9-0.1,1.2-0.1c0.8,0,1.4,0,1.7,0.1c1,0.1,1.8,0.3,2.2,0.5
|
||||
c0.4,0.1,1,0.4,2.3,0.9c0.7,0.3,0.8,0.3,1.1,0.4c0.4,0.1,1.1,0.4,1.9,0.5c0.6,0.1,1.6,0.3,3,0.2c1.2-0.1,2-0.4,2.2-0.5
|
||||
c0.4-0.2,0.7-0.3,1.4-0.7c1.1-0.6,1.7-1,2.8-1.7c1.5-0.9,2.2-1.4,2.9-1.7c1.1-0.5,1.9-0.8,1.9-0.9c0-0.1-0.8,0-1.3,0.1
|
||||
c-0.7,0.2-1.2,0.3-1.2,0.4c-1.1,0.4-2.4,0.8-3.6,1.1c-1.4,0.3-2.4,0.6-3.8,0.6c-1.6,0-2.8-0.3-3.5-0.5c-1-0.2-1.1-0.4-2.7-0.8
|
||||
c-1.1-0.3-1.6-0.4-2.2-0.5c-0.6-0.1-1.7-0.2-3.1,0.1c-0.5,0.1-1.2,0.3-2,0.7C12.6,53.7,11.5,55,11.5,55z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
50
scripts/check-github-workflows.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# bash -n every shell script and every workflow `run:` block. actionlint when present.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
for script in scripts/*.sh; do
|
||||
bash -n "${script}"
|
||||
done
|
||||
|
||||
python3 - "${ROOT}" << 'PY'
|
||||
import pathlib, re, subprocess, sys, tempfile
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
failures = 0
|
||||
workflow_count = 0
|
||||
block_count = 0
|
||||
for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted(
|
||||
(root / ".github/workflows").glob("*.yaml")
|
||||
):
|
||||
workflow_count += 1
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
blocks = []
|
||||
for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M):
|
||||
indent = len(match.group(1)) + 2
|
||||
body = []
|
||||
for line in match.group(2).splitlines():
|
||||
body.append(line[indent:] if len(line) >= indent else line.lstrip())
|
||||
blocks.append("\n".join(body) + "\n")
|
||||
block_count += len(blocks)
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle:
|
||||
handle.write(block)
|
||||
name = handle.name
|
||||
result = subprocess.run(["bash", "-n", name], capture_output=True, text=True)
|
||||
pathlib.Path(name).unlink()
|
||||
if result.returncode != 0:
|
||||
failures += 1
|
||||
sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}")
|
||||
if failures:
|
||||
raise SystemExit(1)
|
||||
print(
|
||||
f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows"
|
||||
)
|
||||
PY
|
||||
|
||||
if command -v actionlint >/dev/null 2>&1; then
|
||||
actionlint -color
|
||||
else
|
||||
echo "actionlint not installed; skipped (CI installs it)"
|
||||
fi
|
||||
509
scripts/check-terraform-import-plan.py
Executable file
|
|
@ -0,0 +1,509 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||
DISTRIBUTION_ADDRESS = (
|
||||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--post-import-no-op",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Require all managed resources to be no-op after import and forbid "
|
||||
"import metadata."
|
||||
),
|
||||
)
|
||||
modes.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||
"Repeat for every expected update."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_plan(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("plan JSON root must be an object")
|
||||
if not isinstance(value.get("resource_changes"), list):
|
||||
raise ValueError("plan JSON must contain a resource_changes array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_import_metadata(
|
||||
*,
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
importing = change.get("importing")
|
||||
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
|
||||
|
||||
import_id = importing.get("id")
|
||||
if not isinstance(import_id, str) or not import_id.strip():
|
||||
return [f"{address}: import ID must be a non-empty string"]
|
||||
if import_id.startswith("REPLACE_WITH_"):
|
||||
return [f"{address}: import ID is still a placeholder"]
|
||||
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if expected is not None and import_id != expected:
|
||||
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
|
||||
|
||||
other_environment_ids = {
|
||||
imports[address]
|
||||
for name, imports in REQUIRED_IMPORT_IDS.items()
|
||||
if name != environment and imports[address] is not None
|
||||
}
|
||||
if import_id in other_environment_ids:
|
||||
return [f"{address}: import ID belongs to another environment"]
|
||||
return []
|
||||
|
||||
|
||||
def _contains_unknown(value: Any) -> bool:
|
||||
if value is True:
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _changed_leaf_paths(
|
||||
before: Any,
|
||||
after: Any,
|
||||
path: tuple[str, ...] = (),
|
||||
) -> set[tuple[str, ...]]:
|
||||
if isinstance(before, dict) and isinstance(after, dict):
|
||||
result: set[tuple[str, ...]] = set()
|
||||
for key in set(before) | set(after):
|
||||
result.update(
|
||||
_changed_leaf_paths(
|
||||
before.get(key),
|
||||
after.get(key),
|
||||
(*path, str(key)),
|
||||
)
|
||||
)
|
||||
return result
|
||||
if before != after:
|
||||
return {path}
|
||||
return set()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _canonical(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
items = [_canonical(item) for item in value]
|
||||
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return value
|
||||
|
||||
|
||||
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
|
||||
if not isinstance(value, str):
|
||||
return None, [f"{address}: {side} policy must be a JSON string"]
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None, [f"{address}: {side} policy is not valid JSON"]
|
||||
if not isinstance(document, dict):
|
||||
return None, [f"{address}: {side} policy must be a JSON object"]
|
||||
return _canonical(document), []
|
||||
|
||||
|
||||
def _distribution_id(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
) -> str | None:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
if isinstance(configured, str):
|
||||
return configured
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
|
||||
continue
|
||||
after = resource.get("change", {}).get("after")
|
||||
if isinstance(after, dict):
|
||||
identifier = after.get("id")
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
return identifier
|
||||
return None
|
||||
|
||||
|
||||
def _expected_pre_adoption_bucket_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_tag_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
invalid = {
|
||||
path
|
||||
for path in changed
|
||||
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||
}
|
||||
violations = [
|
||||
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||
if address == ROLE_ADDRESS:
|
||||
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||
"workspace_name"
|
||||
]
|
||||
if address == BUCKET_ADDRESS:
|
||||
expected["aws-cdk:auto-delete-objects"] = None
|
||||
expected_after = {
|
||||
key: value for key, value in expected.items() if value is not None
|
||||
}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
for path in sorted(changed - invalid):
|
||||
key = path[1]
|
||||
if key not in expected:
|
||||
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||
violations.append(
|
||||
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||
)
|
||||
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||
violations.append(
|
||||
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||
)
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: policy semantics did not change")
|
||||
if distribution_id is None:
|
||||
violations.append(
|
||||
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||
)
|
||||
return violations
|
||||
expected_before = _expected_pre_adoption_bucket_policy(
|
||||
environment, distribution_id
|
||||
)
|
||||
expected_after = _expected_bucket_policy(environment, distribution_id)
|
||||
if before_policy is not None and before_policy != expected_before:
|
||||
violations.append(f"{address}: pre-adoption policy semantics are not exact")
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
replace_paths = change.get("replace_paths", [])
|
||||
if replace_paths not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
if _contains_unknown(change.get("after_unknown", {})):
|
||||
violations.append(f"{address}: controlled update contains unknown values")
|
||||
before = change.get("before")
|
||||
after = change.get("after")
|
||||
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address == BUCKET_POLICY_ADDRESS:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
address,
|
||||
before,
|
||||
after,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
environment: str,
|
||||
mode: str,
|
||||
allowed_updates: set[str],
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||
for address in sorted(invalid_allowed):
|
||||
violations.append(
|
||||
f"{address}: address is not eligible for the controlled adoption update"
|
||||
)
|
||||
|
||||
distribution_id = _distribution_id(plan, environment)
|
||||
seen_addresses: set[str] = set()
|
||||
seen_updates: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[environment]
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict):
|
||||
violations.append("<unknown>: resource change must be an object")
|
||||
continue
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address")
|
||||
if not isinstance(address, str):
|
||||
violations.append("<unknown>: managed resource has no valid address")
|
||||
continue
|
||||
if address in seen_addresses:
|
||||
violations.append(f"{address}: duplicate managed resource change")
|
||||
seen_addresses.add(address)
|
||||
|
||||
expected_type = required_resources.get(address)
|
||||
if expected_type is None:
|
||||
violations.append(f"{address}: managed address is outside the ownership boundary")
|
||||
elif resource.get("type") != expected_type:
|
||||
violations.append(
|
||||
f"{address}: expected managed type {expected_type!r}, "
|
||||
f"got {resource.get('type')!r}"
|
||||
)
|
||||
|
||||
change = resource.get("change")
|
||||
if not isinstance(change, dict):
|
||||
violations.append(f"{address}: missing change object")
|
||||
continue
|
||||
actions = change.get("actions")
|
||||
if not isinstance(actions, list) or not all(
|
||||
isinstance(action, str) for action in actions
|
||||
):
|
||||
violations.append(f"{address}: actions must be a string array")
|
||||
continue
|
||||
|
||||
if change.get("replace_paths") not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
|
||||
if mode == "import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
elif mode == "post-import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: post-import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in post-import mode"
|
||||
)
|
||||
else:
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in controlled-update mode"
|
||||
)
|
||||
if actions == ["update"]:
|
||||
seen_updates.add(address)
|
||||
if address not in allowed_updates:
|
||||
violations.append(f"{address}: update is not explicitly allowlisted")
|
||||
else:
|
||||
violations.extend(
|
||||
_validate_controlled_update(
|
||||
address,
|
||||
change,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
elif actions != ["no-op"]:
|
||||
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||
|
||||
for missing in sorted(set(required_resources) - seen_addresses):
|
||||
violations.append(f"{missing}: required managed resource is absent")
|
||||
for unused in sorted(allowed_updates - seen_updates):
|
||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
plan = _load_plan(args.plan_json)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
allowed_updates = set(args.allow_update_address or [])
|
||||
if args.post_import_no_op:
|
||||
mode = "post-import"
|
||||
elif allowed_updates:
|
||||
mode = "controlled"
|
||||
else:
|
||||
mode = "import"
|
||||
violations = check_plan(
|
||||
plan,
|
||||
environment=args.environment,
|
||||
mode=mode,
|
||||
allowed_updates=allowed_updates,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
label = {
|
||||
"import": "zero-change import",
|
||||
"post-import": "post-import no-op",
|
||||
"controlled": "controlled update",
|
||||
}[mode]
|
||||
print(
|
||||
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
|
||||
f"managed resources and {len(allowed_updates)} exact updates"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
137
scripts/check-terraform-isolation.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Terraform/application change isolation gate.
|
||||
//
|
||||
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
|
||||
// on the workspace. If the same merge also changes deployable application
|
||||
// code, the content release and the VCS run race for the workspace lock
|
||||
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
|
||||
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
|
||||
// or discarded by a human before the next content release.
|
||||
//
|
||||
// Files that may accompany a Terraform change without triggering a release:
|
||||
// the Terraform tree itself, its plan-guard tooling, and documentation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
|
||||
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
|
||||
//
|
||||
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
|
||||
// it only when the PR carries the `terraform-isolation-override` label, which
|
||||
// reviewers grant to the rare change that must introduce Terraform variables
|
||||
// together with the workflow that consumes them. The checker has no memory of
|
||||
// a previous pass: the same mixed diff fails again as soon as the override
|
||||
// env is unset (label removal).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const OVERRIDE_LABEL = "terraform-isolation-override";
|
||||
|
||||
export function isTerraformPath(file) {
|
||||
return file.startsWith("terraform/");
|
||||
}
|
||||
|
||||
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
|
||||
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
|
||||
// Terraform change for the mixed-PR check.
|
||||
export function isTerraformInfrastructurePath(file) {
|
||||
return isTerraformPath(file) && !file.endsWith(".md");
|
||||
}
|
||||
|
||||
export function mayAccompanyTerraform(file) {
|
||||
if (isTerraformPath(file)) return true;
|
||||
if (file.endsWith(".md")) return true;
|
||||
if (file.startsWith("docs/")) return true;
|
||||
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
|
||||
if (
|
||||
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (file.startsWith("scripts/testdata/terraform-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files changed paths relative to the repository root
|
||||
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
|
||||
const terraform = unique.filter(isTerraformInfrastructurePath);
|
||||
const application = unique.filter((file) => !mayAccompanyTerraform(file));
|
||||
return {
|
||||
terraform,
|
||||
application,
|
||||
mixed: terraform.length > 0 && application.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function changedFilesFromGit(base, head) {
|
||||
const mergeBase = execFileSync("git", ["merge-base", base, head], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { base: null, head: "HEAD", stdin: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--base") options.base = argv[++index];
|
||||
else if (argument === "--head") options.head = argv[++index];
|
||||
else if (argument === "--stdin") options.stdin = true;
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!options.stdin && !options.base) {
|
||||
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArgs(argv);
|
||||
const files = options.stdin
|
||||
? readFileSync(0, "utf8").split("\n")
|
||||
: changedFilesFromGit(options.base, options.head);
|
||||
const result = classifyChangedFiles(files);
|
||||
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
|
||||
);
|
||||
if (!result.mixed) {
|
||||
console.log(" PASS: Terraform and application changes are not mixed");
|
||||
return 0;
|
||||
}
|
||||
console.log(" Terraform files:");
|
||||
for (const file of result.terraform) console.log(` ${file}`);
|
||||
console.log(" Application files that cannot ship in the same PR:");
|
||||
for (const file of result.application) console.log(` ${file}`);
|
||||
if (override) {
|
||||
console.log(
|
||||
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
179
scripts/check-terraform-isolation.test.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
isTerraformInfrastructurePath,
|
||||
mayAccompanyTerraform,
|
||||
} from "./check-terraform-isolation.mjs";
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"check-terraform-isolation.mjs",
|
||||
);
|
||||
|
||||
function runGate(files, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
|
||||
input: `${files.join("\n")}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
|
||||
for (const file of [
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
"terraform/README.md",
|
||||
"README.md",
|
||||
"docs/adr/0003-terraform.md",
|
||||
"scripts/check-terraform-import-plan.py",
|
||||
"scripts/terraform_import_plan_resources.py",
|
||||
"scripts/test-terraform-import-plan-check.py",
|
||||
"scripts/terraform-validate.mjs",
|
||||
"scripts/check-terraform-isolation.mjs",
|
||||
"scripts/check-terraform-release-plan.py",
|
||||
"scripts/hcp-run-guard.py",
|
||||
"scripts/test-hcp-run-guard.py",
|
||||
"scripts/verify-cloudfront-release.sh",
|
||||
"scripts/test-verify-cloudfront-release.sh",
|
||||
"scripts/summarize-cloudfront-live-state.sh",
|
||||
"scripts/check-github-workflows.sh",
|
||||
"scripts/read-release-pointer.py",
|
||||
"scripts/testdata/terraform-release-plans/version-only.json",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("terraform-only and application-only changes are not mixed", () => {
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(classifyChangedFiles([]).mixed, false);
|
||||
});
|
||||
|
||||
test("terraform documentation does not mix with application or workflow changes", () => {
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
|
||||
false,
|
||||
);
|
||||
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
|
||||
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
|
||||
assert.match(docsOnly.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("terraform plus application is mixed and lists the offending files", () => {
|
||||
const result = classifyChangedFiles([
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/App.tsx",
|
||||
"README.md",
|
||||
" ",
|
||||
"src/App.tsx",
|
||||
]);
|
||||
assert.equal(result.mixed, true);
|
||||
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
|
||||
assert.deepEqual(result.application, ["src/App.tsx"]);
|
||||
});
|
||||
|
||||
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
|
||||
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
|
||||
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
||||
assert.match(mixed.stdout, /FAIL/);
|
||||
assert.match(mixed.stdout, /src\/App\.tsx/);
|
||||
|
||||
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
|
||||
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
|
||||
assert.match(isolated.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("CLI override downgrades a mixed change to a warning that names the label", () => {
|
||||
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /WARNING/);
|
||||
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
|
||||
|
||||
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "yes",
|
||||
});
|
||||
assert.equal(notTrue.status, 1);
|
||||
});
|
||||
|
||||
test("removing the override fails a mixed change that was previously green", () => {
|
||||
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
|
||||
const previouslyGreen = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
|
||||
assert.match(previouslyGreen.stdout, /WARNING/);
|
||||
|
||||
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
|
||||
// the string "false" after the label is removed. A stale green check must
|
||||
// not survive that.
|
||||
const afterLabelRemoved = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "false",
|
||||
});
|
||||
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
|
||||
assert.match(afterLabelRemoved.stdout, /FAIL/);
|
||||
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
|
||||
});
|
||||
|
||||
test("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
|
||||
const workflows = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
".github/workflows",
|
||||
);
|
||||
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
|
||||
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
|
||||
|
||||
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
|
||||
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
|
||||
|
||||
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
|
||||
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
|
||||
});
|
||||
533
scripts/check-terraform-release-plan.py
Executable file
|
|
@ -0,0 +1,533 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject HCP Terraform plans that are not a frontend content-release update.
|
||||
|
||||
Accepts exactly:
|
||||
- an update of the release pointer (content, plus computed etag/version_id)
|
||||
- an update of the distribution with only origin[*].origin_path changed
|
||||
(response_completion_timeout 0, null, and a missing key are equivalent)
|
||||
- exactly one action invocation for the CloudFront invalidation
|
||||
|
||||
after origin_path values must match the expected labels. before origin_path
|
||||
values must match the pointer's prior current/previous. This script may read a
|
||||
local plan JSON file or download plan JSON from the documented HashiCorp
|
||||
endpoint:
|
||||
|
||||
GET https://app.terraform.io/api/v2/plans/:id/json-output
|
||||
|
||||
The download follows exactly one redirect, and only to archivist.terraform.io.
|
||||
It does not create, apply, discard, or poll runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
|
||||
DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
ACTION_ADDRESS = (
|
||||
"module.environment_owned.action.aws_cloudfront_create_invalidation.release"
|
||||
)
|
||||
API_HOST = "app.terraform.io"
|
||||
ARCHIVE_HOST = "archivist.terraform.io"
|
||||
PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
|
||||
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
||||
IGNORED_ACTIONS = {"no-op", "read"}
|
||||
UNSAFE_ACTIONS = {"create", "delete"}
|
||||
POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"})
|
||||
DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"etag",
|
||||
"last_modified_time",
|
||||
"status",
|
||||
"in_progress_validation_batches",
|
||||
}
|
||||
)
|
||||
# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset;
|
||||
# the provider writes null on origin_path updates. Treat 0, null, and a
|
||||
# missing key as the same. Any other value still fails closed.
|
||||
ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout"
|
||||
ORIGIN_TIMEOUT_UNSET = frozenset({0, None})
|
||||
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||
|
||||
UrlOpen = Callable[..., Any]
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Return the redirect response instead of following it."""
|
||||
|
||||
def http_error_301(self, req, fp, code, msg, headers):
|
||||
return self._capture(req, fp, code, headers)
|
||||
|
||||
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
||||
|
||||
@staticmethod
|
||||
def _capture(req, fp, code, headers):
|
||||
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
||||
response.msg = "Redirect"
|
||||
return response
|
||||
|
||||
|
||||
def _urlopen_without_redirects(
|
||||
*handlers: urllib.request.BaseHandler,
|
||||
) -> UrlOpen:
|
||||
context = ssl.create_default_context()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
_NoRedirectHandler,
|
||||
*handlers,
|
||||
)
|
||||
return opener.open
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument(
|
||||
"plan_json",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
help="Local Terraform plan JSON. Mutually exclusive with --plan-id.",
|
||||
)
|
||||
source.add_argument(
|
||||
"--plan-id",
|
||||
help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-version-label",
|
||||
required=True,
|
||||
help="Immutable current release the plan must apply. Empty string is the legacy root.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-previous-version-label",
|
||||
default="",
|
||||
help="Previous release label the origin group must fail over to.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evidence-out",
|
||||
type=Path,
|
||||
help="Write machine-readable proof after every assertion passes.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def download_plan_json(
|
||||
plan_id: str,
|
||||
token: str,
|
||||
*,
|
||||
urlopen: UrlOpen | None = None,
|
||||
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
if not PLAN_ID_RE.fullmatch(plan_id):
|
||||
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
||||
if not token:
|
||||
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
||||
|
||||
opener = urlopen or _urlopen_without_redirects(*handlers)
|
||||
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
||||
request = urllib.request.Request(
|
||||
api_url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
first = _open_pinned(opener, request, allowed_host=API_HOST)
|
||||
try:
|
||||
if first.status == 204:
|
||||
raise ValueError(
|
||||
"plan JSON is not ready; refusing to poll the plans endpoint"
|
||||
)
|
||||
if first.status not in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
||||
)
|
||||
location = first.headers.get("Location")
|
||||
if not location:
|
||||
raise ValueError(f"{API_HOST} redirect is missing a Location header")
|
||||
archive = urlparse(location)
|
||||
if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST:
|
||||
raise ValueError(
|
||||
"refusing redirect that is not https://"
|
||||
f"{ARCHIVE_HOST}/"
|
||||
)
|
||||
archive_request = urllib.request.Request(location, method="GET")
|
||||
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
||||
try:
|
||||
if second.status in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"refusing a second redirect from {ARCHIVE_HOST}"
|
||||
)
|
||||
if second.status != 200:
|
||||
raise ValueError(
|
||||
f"plan JSON download from {ARCHIVE_HOST} returned "
|
||||
f"HTTP {second.status}"
|
||||
)
|
||||
payload = second.read()
|
||||
finally:
|
||||
second.close()
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
plan = json.loads(payload.decode("utf-8"))
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("plan JSON must be an object")
|
||||
return plan
|
||||
|
||||
|
||||
def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str):
|
||||
parsed = urlparse(request.full_url)
|
||||
if parsed.scheme != "https" or parsed.hostname != allowed_host:
|
||||
raise ValueError(
|
||||
f"refusing to contact {parsed.scheme}://{parsed.hostname} "
|
||||
f"(pinned host is {allowed_host})"
|
||||
)
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
return urlopen(request, context=context, timeout=30)
|
||||
except TypeError:
|
||||
return urlopen(request, timeout=30)
|
||||
|
||||
|
||||
def _is_nested_unknown(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def changed_attributes(
|
||||
change: dict[str, Any],
|
||||
*,
|
||||
computed_unknown: frozenset[str],
|
||||
) -> set[str]:
|
||||
before = change.get("before") or {}
|
||||
after = change.get("after") or {}
|
||||
unknown = change.get("after_unknown") or {}
|
||||
keys = set(before) | set(after) | set(unknown)
|
||||
changed: set[str] = set()
|
||||
for key in keys:
|
||||
unknown_value = unknown.get(key)
|
||||
if unknown_value is True:
|
||||
if key in computed_unknown:
|
||||
continue
|
||||
changed.add(key)
|
||||
continue
|
||||
if _is_nested_unknown(unknown_value):
|
||||
changed.add(key)
|
||||
continue
|
||||
if before.get(key) != after.get(key):
|
||||
changed.add(key)
|
||||
return changed
|
||||
|
||||
|
||||
def _label_ok(label: str) -> bool:
|
||||
return label == "" or bool(VERSION_LABEL_RE.fullmatch(label))
|
||||
|
||||
|
||||
def origin_path_for_label(label: str) -> str:
|
||||
return "" if label == "" else f"/releases/{label}"
|
||||
|
||||
|
||||
def _origin_map(origins: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(origins, list):
|
||||
return {}
|
||||
mapped: dict[str, dict[str, Any]] = {}
|
||||
for origin in origins:
|
||||
if not isinstance(origin, dict):
|
||||
continue
|
||||
origin_id = origin.get("origin_id")
|
||||
if not isinstance(origin_id, str) or not origin_id:
|
||||
continue
|
||||
mapped[origin_id] = origin
|
||||
return mapped
|
||||
|
||||
|
||||
def _origin_paths(origins: Any) -> dict[str, str]:
|
||||
return {
|
||||
origin_id: origin.get("origin_path") or ""
|
||||
for origin_id, origin in _origin_map(origins).items()
|
||||
}
|
||||
|
||||
|
||||
def _decode_pointer(content: Any) -> dict[str, str]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return {
|
||||
"current": payload.get("current") or "",
|
||||
"previous": payload.get("previous") or "",
|
||||
}
|
||||
|
||||
|
||||
def _validate_pointer(
|
||||
resource: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES)
|
||||
if changed != {"content"}:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: expected only content to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
after = _decode_pointer((change.get("after") or {}).get("content"))
|
||||
if after.get("current") != expected_current:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match "
|
||||
f"{expected_current!r}"
|
||||
)
|
||||
if after.get("previous") != expected_previous:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match "
|
||||
f"{expected_previous!r}"
|
||||
)
|
||||
unknown = change.get("after_unknown") or {}
|
||||
if unknown.get("content") is True:
|
||||
violations.append(f"{POINTER_ADDRESS}: content after value is unknown")
|
||||
return violations
|
||||
|
||||
|
||||
def _origin_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]:
|
||||
rest = {key: value for key, value in origin.items() if key != "origin_path"}
|
||||
timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT)
|
||||
if timeout in ORIGIN_TIMEOUT_UNSET:
|
||||
rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None)
|
||||
return rest
|
||||
|
||||
|
||||
def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool:
|
||||
return _origin_fields_for_compare(before) != _origin_fields_for_compare(after)
|
||||
|
||||
|
||||
def _validate_distribution(
|
||||
resource: dict[str, Any],
|
||||
pointer_before: dict[str, str],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(
|
||||
change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES
|
||||
)
|
||||
if changed != {"origin"}:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
return violations
|
||||
|
||||
before_origins = _origin_map((change.get("before") or {}).get("origin"))
|
||||
after_origins = _origin_map((change.get("after") or {}).get("origin"))
|
||||
if set(before_origins) != set(after_origins):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin IDs changed "
|
||||
f"from {sorted(before_origins)} to {sorted(after_origins)}"
|
||||
)
|
||||
return violations
|
||||
|
||||
for origin_id, before_origin in before_origins.items():
|
||||
if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path"
|
||||
)
|
||||
|
||||
after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values())
|
||||
expected_after = sorted(
|
||||
[
|
||||
origin_path_for_label(expected_current),
|
||||
origin_path_for_label(expected_previous),
|
||||
]
|
||||
)
|
||||
if after_paths != expected_after:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match "
|
||||
f"{expected_after}"
|
||||
)
|
||||
|
||||
before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values())
|
||||
expected_before = sorted(
|
||||
[
|
||||
origin_path_for_label(pointer_before.get("current", "")),
|
||||
origin_path_for_label(pointer_before.get("previous", "")),
|
||||
]
|
||||
)
|
||||
if before_paths != expected_before:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match "
|
||||
f"pointer prior values {expected_before}"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_actions(plan: dict[str, Any]) -> list[str]:
|
||||
invocations = plan.get("action_invocations")
|
||||
if invocations is None:
|
||||
return ["plan is missing action_invocations"]
|
||||
if not isinstance(invocations, list):
|
||||
return ["action_invocations must be a list"]
|
||||
addresses = [
|
||||
item.get("address")
|
||||
for item in invocations
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if addresses != [ACTION_ADDRESS]:
|
||||
return [
|
||||
"expected exactly one action_invocations entry "
|
||||
f"{ACTION_ADDRESS}, found {addresses}"
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def validate_plan(
|
||||
plan: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
if not _label_ok(expected_current):
|
||||
violations.append(
|
||||
"expected version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
if not _label_ok(expected_previous):
|
||||
violations.append(
|
||||
"expected previous version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
|
||||
updates: dict[str, dict[str, Any]] = {}
|
||||
for resource in plan.get("resource_changes", []):
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address", "<unknown>")
|
||||
change = resource.get("change") or {}
|
||||
actions = list(change.get("actions") or [])
|
||||
action_set = set(actions)
|
||||
if action_set <= IGNORED_ACTIONS:
|
||||
continue
|
||||
|
||||
if change.get("importing"):
|
||||
violations.append(f"{address}: import actions are not allowed")
|
||||
|
||||
unsafe = sorted(action_set & UNSAFE_ACTIONS)
|
||||
if unsafe:
|
||||
violations.append(f"{address}: unsafe actions {unsafe}")
|
||||
if "replace" in action_set or actions in (
|
||||
["delete", "create"],
|
||||
["create", "delete"],
|
||||
):
|
||||
violations.append(f"{address}: replacement is not allowed")
|
||||
|
||||
if "update" in action_set:
|
||||
updates[address] = resource
|
||||
if action_set != {"update"}:
|
||||
violations.append(
|
||||
f"{address}: update must be the only action, got {actions}"
|
||||
)
|
||||
|
||||
if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and (
|
||||
action_set - IGNORED_ACTIONS
|
||||
):
|
||||
violations.append(
|
||||
f"{address}: managed address is outside the content-release update"
|
||||
)
|
||||
|
||||
if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}:
|
||||
violations.append(
|
||||
"expected exactly the pointer and distribution updates, found "
|
||||
f"{sorted(updates)}"
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
return violations
|
||||
|
||||
pointer_change = updates[POINTER_ADDRESS].get("change") or {}
|
||||
pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content"))
|
||||
violations.extend(
|
||||
_validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous)
|
||||
)
|
||||
violations.extend(
|
||||
_validate_distribution(
|
||||
updates[DISTRIBUTION_ADDRESS],
|
||||
pointer_before,
|
||||
expected_current,
|
||||
expected_previous,
|
||||
)
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.plan_id:
|
||||
try:
|
||||
plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", ""))
|
||||
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
|
||||
print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
if args.plan_json is None:
|
||||
print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr)
|
||||
return 1
|
||||
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
|
||||
|
||||
violations = validate_plan(
|
||||
plan,
|
||||
args.expected_version_label,
|
||||
args.expected_previous_version_label,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not a content-release update", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.evidence_out:
|
||||
evidence = {
|
||||
"pointer_address": POINTER_ADDRESS,
|
||||
"distribution_address": DISTRIBUTION_ADDRESS,
|
||||
"action_address": ACTION_ADDRESS,
|
||||
"expected_version_label": args.expected_version_label,
|
||||
"expected_previous_version_label": args.expected_previous_version_label,
|
||||
"managed_updates": 2,
|
||||
"action_invocations": 1,
|
||||
"creates": 0,
|
||||
"deletes": 0,
|
||||
"replacements": 0,
|
||||
}
|
||||
args.evidence_out.write_text(
|
||||
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
"PASS: content-release plan updates "
|
||||
f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to "
|
||||
f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
79
scripts/deploy-web.sh
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--id "${INVALIDATION_ID}"
|
||||
fi
|
||||
|
||||
echo "Web deploy complete."
|
||||
6
scripts/governance-baseline.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"version": 1,
|
||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||
"maxFileLines": 500,
|
||||
"godfileDebt": []
|
||||
}
|
||||
329
scripts/governance-check.mjs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
||||
|
||||
const MAX_FILE_LINES = 500;
|
||||
const MAINTAINABILITY_RULES = [
|
||||
'complexity: ["error", { "max": 20 }]',
|
||||
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
|
||||
'max-params: ["error", 4]',
|
||||
'max-depth: ["error", 4]',
|
||||
];
|
||||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
// Repository-level gates that run after the source gates. Each is an npm
|
||||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform release-plan contract", "test:terraform-release-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["HCP run guard", "test:hcp-run-guard"],
|
||||
["CloudFront release verify", "test:cloudfront-release-verify"],
|
||||
["GitHub workflow shell", "test:github-workflows"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
|
||||
/\.(ts|tsx)$/.test(relativePath) &&
|
||||
!EXCLUDE_DIR.test(relativePath) &&
|
||||
!EXCLUDE_NAME.test(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function gitText(args) {
|
||||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function gitLines(args) {
|
||||
return gitText(args).split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function governedFiles() {
|
||||
const tracked = gitLines(["ls-files"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...tracked, ...untracked])].filter(
|
||||
(relativePath) => isGoverned(relativePath) && existsSync(path.join(ROOT, relativePath)),
|
||||
);
|
||||
}
|
||||
|
||||
function lineCount(relativePath) {
|
||||
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function readBaseline() {
|
||||
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function readBaselineAtRef(ref) {
|
||||
try {
|
||||
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function godfileRatchet(baseRef) {
|
||||
const baseline = readBaseline();
|
||||
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
const debtEntries = new Map(
|
||||
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
const files = governedFiles();
|
||||
|
||||
const newDebt = [];
|
||||
const grownDebt = [];
|
||||
for (const file of files) {
|
||||
const lines = lineCount(file);
|
||||
const debtCap = debtEntries.get(file);
|
||||
if (lines > cap && debtCap === undefined) {
|
||||
newDebt.push({ path: file, lines });
|
||||
} else if (debtCap !== undefined && lines > debtCap) {
|
||||
grownDebt.push({ path: file, lines, maxLines: debtCap });
|
||||
}
|
||||
}
|
||||
|
||||
const stale = [];
|
||||
const remaining = [];
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
|
||||
if (lines === -1 || lines <= cap) {
|
||||
stale.push({ path: debtPath, lines });
|
||||
} else {
|
||||
remaining.push({ path: debtPath, lines, maxLines });
|
||||
}
|
||||
}
|
||||
|
||||
const baselineLoosening = [];
|
||||
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
|
||||
if (baseBaseline) {
|
||||
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
if (cap > baseCap) {
|
||||
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
|
||||
}
|
||||
const baseEntries = new Map(
|
||||
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const priorMax = baseEntries.get(debtPath);
|
||||
if (priorMax === undefined) {
|
||||
baselineLoosening.push(`new debt entry: ${debtPath}`);
|
||||
} else if (maxLines > priorMax) {
|
||||
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cap,
|
||||
newDebt,
|
||||
grownDebt,
|
||||
stale,
|
||||
remaining,
|
||||
baselineLoosening,
|
||||
comparedBaseline: Boolean(baseBaseline),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
for (const candidate of ["origin/dev", "origin/main"]) {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", candidate], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
});
|
||||
return candidate;
|
||||
} catch {
|
||||
// candidate ref not present locally; try the next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function changedGovernedFiles(baseRef) {
|
||||
let mergeBase;
|
||||
try {
|
||||
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
|
||||
}
|
||||
|
||||
function maintainabilityGate(files) {
|
||||
if (files.length === 0) {
|
||||
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||
}
|
||||
// Invoke eslint via node so Windows (no shebang exec) and Unix both work.
|
||||
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
|
||||
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
eslintJs,
|
||||
...files,
|
||||
...ruleArgs,
|
||||
"--max-warnings=0",
|
||||
"--no-warn-ignored",
|
||||
"--no-error-on-unmatched-pattern",
|
||||
],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
);
|
||||
return {
|
||||
skipped: false,
|
||||
status: result.status,
|
||||
stdout: result.stdout?.trim() ?? "",
|
||||
stderr: result.stderr?.trim() ?? "",
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function plural(count, word) {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function runRepositoryGate(label, script) {
|
||||
// Reuse the npm that launched us when available (matches its version and
|
||||
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
|
||||
const npmCli = process.env.npm_execpath;
|
||||
const executable = npmCli ? process.execPath : "npm";
|
||||
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
if (!baseRef) {
|
||||
failures.push(
|
||||
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
gitText(["merge-base", baseRef, "HEAD"]);
|
||||
} catch {
|
||||
failures.push(
|
||||
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log("godfile ratchet: legacy caps may only shrink");
|
||||
const god = godfileRatchet(baseRef);
|
||||
console.log(
|
||||
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
|
||||
);
|
||||
for (const entry of god.remaining) {
|
||||
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.newDebt) {
|
||||
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.grownDebt) {
|
||||
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
if (god.newDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
|
||||
);
|
||||
}
|
||||
if (god.grownDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
|
||||
);
|
||||
}
|
||||
if (god.baselineLoosening.length > 0) {
|
||||
failures.push(
|
||||
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
|
||||
);
|
||||
}
|
||||
if (!god.comparedBaseline) {
|
||||
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
|
||||
}
|
||||
if (god.stale.length > 0) {
|
||||
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
|
||||
for (const entry of god.stale) {
|
||||
console.log(` stale ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (!baseRef) {
|
||||
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
|
||||
} else {
|
||||
const files = changedGovernedFiles(baseRef);
|
||||
console.log(
|
||||
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
|
||||
);
|
||||
if (files === null) {
|
||||
console.log(" failed — base ref could not be resolved against HEAD");
|
||||
} else {
|
||||
const gate = maintainabilityGate(files);
|
||||
if (gate.skipped) {
|
||||
console.log(` skipped — ${gate.reason}`);
|
||||
} else {
|
||||
const clean = gate.status === 0;
|
||||
console.log(
|
||||
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
|
||||
);
|
||||
if (!clean) {
|
||||
if (gate.stdout) console.log(gate.stdout);
|
||||
if (gate.stderr) console.log(gate.stderr);
|
||||
failures.push(
|
||||
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, script] of REPOSITORY_GATES) {
|
||||
console.log("─".repeat(64));
|
||||
console.log(`${label}: npm run ${script}`);
|
||||
const gate = runRepositoryGate(label, script);
|
||||
if (gate.error) {
|
||||
failures.push(`${label}: could not start: ${gate.error.message}`);
|
||||
} else if (gate.status !== 0) {
|
||||
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
for (const failure of failures) console.log(` - ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("RESULT: PASS — all governance gates green");
|
||||
}
|
||||
|
||||
main();
|
||||
207
scripts/hcp-run-guard.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Guard HCP Terraform runs used by GitHub content CD.
|
||||
|
||||
Subcommands:
|
||||
check-and-discard Refuse unsafe workspace settings. Discard a blocking
|
||||
non-speculative VCS run so GitHub CD can create-run.
|
||||
reconcile-apply Treat an HCP run whose status is already ``applied`` as
|
||||
success when the GitHub apply-run step reported failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
API = "https://app.terraform.io/api/v2"
|
||||
DEFAULT_WORKSPACE = "shoc-frontend-new-dev"
|
||||
EXPECTED_TRIGGER_PATTERNS = [
|
||||
"terraform/live/dev/**",
|
||||
"terraform/live/modules/**",
|
||||
]
|
||||
DISCARDABLE = {
|
||||
"pending",
|
||||
"planned",
|
||||
"cost_estimated",
|
||||
"policy_checked",
|
||||
"policy_override",
|
||||
}
|
||||
APPLYING = {"applying", "apply_queued"}
|
||||
|
||||
HttpGet = Callable[[str], dict[str, Any]]
|
||||
HttpPost = Callable[[str, dict[str, Any]], int]
|
||||
|
||||
|
||||
class GuardError(Exception):
|
||||
"""Refused to continue."""
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
}
|
||||
|
||||
|
||||
def default_get(token: str) -> HttpGet:
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(url, headers=_headers(token))
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def default_post(token: str) -> HttpPost:
|
||||
def post(url: str, payload: dict[str, Any]) -> int:
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method="POST", headers=_headers(token)
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return int(response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (409, 404):
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
print(f"discard returned HTTP {exc.code}: {body}")
|
||||
return exc.code
|
||||
raise
|
||||
|
||||
return post
|
||||
|
||||
|
||||
def require_token(token: str) -> str:
|
||||
if not token:
|
||||
raise GuardError("TF_API_TOKEN is required")
|
||||
return token
|
||||
|
||||
|
||||
def check_invariants(attrs: dict[str, Any], workspace: str) -> None:
|
||||
if attrs.get("auto-apply") is True:
|
||||
raise GuardError(f"{workspace} auto-apply is on; refuse to continue")
|
||||
if not attrs.get("speculative-enabled"):
|
||||
raise GuardError("speculative plans are off; refuse to continue")
|
||||
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
|
||||
raise GuardError("tag-based VCS triggering is set; refuse to continue")
|
||||
if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS:
|
||||
raise GuardError(
|
||||
"trigger-patterns must be "
|
||||
f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}"
|
||||
)
|
||||
|
||||
|
||||
def check_and_discard(
|
||||
*,
|
||||
workspace: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
post: HttpPost | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
get = get or default_get(token)
|
||||
post = post or default_post(token)
|
||||
workspace_payload = get(
|
||||
f"{API}/organizations/seahaven/workspaces/{workspace}"
|
||||
)["data"]
|
||||
attrs = workspace_payload["attributes"]
|
||||
check_invariants(attrs, workspace)
|
||||
if not attrs.get("locked"):
|
||||
print("workspace is unlocked")
|
||||
return 0
|
||||
|
||||
current = (
|
||||
workspace_payload.get("relationships", {})
|
||||
.get("current-run", {})
|
||||
.get("data")
|
||||
)
|
||||
if not current:
|
||||
raise GuardError("workspace is locked without a current run")
|
||||
run_id = current["id"]
|
||||
run = get(f"{API}/runs/{run_id}")["data"]
|
||||
run_attrs = run["attributes"]
|
||||
status = run_attrs.get("status")
|
||||
plan_only = run_attrs.get("plan-only")
|
||||
print(f"current run {run_id} status={status} plan-only={plan_only}")
|
||||
if plan_only:
|
||||
print("speculative run does not block GitHub CD")
|
||||
return 0
|
||||
if status in APPLYING:
|
||||
raise GuardError(f"{run_id} is {status}; wait, do not discard an apply")
|
||||
if status not in DISCARDABLE:
|
||||
raise GuardError(f"{run_id} status {status} is not discardable")
|
||||
code = post(
|
||||
f"{API}/runs/{run_id}/actions/discard",
|
||||
{
|
||||
"comment": (
|
||||
"Discarded so GitHub CD can create the content-release applyable run"
|
||||
)
|
||||
},
|
||||
)
|
||||
print(f"discarded {run_id} http={code}")
|
||||
return 0
|
||||
|
||||
|
||||
def reconcile_apply(
|
||||
*,
|
||||
run_id: str,
|
||||
apply_outcome: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
if not run_id:
|
||||
raise GuardError("run id is required")
|
||||
if apply_outcome == "success":
|
||||
print("Apply succeeded.")
|
||||
return 0
|
||||
get = get or default_get(token)
|
||||
status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"]
|
||||
print(f"HCP run {run_id} status={status}")
|
||||
if status == "applied":
|
||||
return 0
|
||||
raise GuardError(
|
||||
f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
check = sub.add_parser("check-and-discard")
|
||||
check.add_argument("--workspace", default=DEFAULT_WORKSPACE)
|
||||
check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
|
||||
reconcile = sub.add_parser("reconcile-apply")
|
||||
reconcile.add_argument("--run-id", required=True)
|
||||
reconcile.add_argument(
|
||||
"--apply-outcome",
|
||||
default=os.environ.get("APPLY_OUTCOME", ""),
|
||||
)
|
||||
reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
if args.command == "check-and-discard":
|
||||
return check_and_discard(workspace=args.workspace, token=args.token)
|
||||
return reconcile_apply(
|
||||
run_id=args.run_id,
|
||||
apply_outcome=args.apply_outcome,
|
||||
token=args.token,
|
||||
)
|
||||
except GuardError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
29
scripts/read-release-pointer.py
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
data = json.loads(raw) if raw else {}
|
||||
current = data.get("current") or ""
|
||||
previous = data.get("previous") or ""
|
||||
output_path = os.environ["GITHUB_OUTPUT"]
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"live_current={current}\n")
|
||||
handle.write(f"live_previous={previous}\n")
|
||||
print(
|
||||
"Pointer live current="
|
||||
+ (current or "<empty>")
|
||||
+ " previous="
|
||||
+ (previous or "<empty>")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
23
scripts/summarize-cloudfront-live-state.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# Print pointer body, origin paths, distribution status, and served index hash.
|
||||
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
|
||||
set -u
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
|
||||
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
|
||||
echo "=== CloudFront live state ==="
|
||||
echo "pointer:"
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
|
||||
import json, sys
|
||||
payload = json.load(sys.stdin)
|
||||
dist = payload.get("Distribution") or {}
|
||||
config = dist.get("DistributionConfig") or {}
|
||||
print("status:", dist.get("Status"))
|
||||
for origin in ((config.get("Origins") or {}).get("Items") or []):
|
||||
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
|
||||
'
|
||||
echo
|
||||
echo -n "served index sha256: "
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
|
||||