diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..15f9114e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Summary + + + +## Changes and value + + + +## Ticket + + diff --git a/.github/renovate.json b/.github/renovate.json index 38bd081a..5778715b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "enabledManagers": ["npm", "custom.regex", "terraform"], + "enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"], + "schedule": ["before 6am every weekday"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", "customManagers": [ @@ -14,6 +15,17 @@ "datasourceTemplate": "npm", "depNameTemplate": "@playwright/test", "versioningTemplate": "npm" + }, + { + "customType": "regex", + "description": [ + "actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard" + ], + "managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"], + "matchStrings": ["ACTIONLINT_VERSION: \"(?\\d+\\.\\d+\\.\\d+)\""], + "datasourceTemplate": "github-releases", + "depNameTemplate": "rhysd/actionlint", + "extractVersionTemplate": "^v(?.*)$" } ], "packageRules": [ @@ -36,6 +48,13 @@ "dependencyDashboardApproval": true, "groupName": "playwright" }, + { + "description": [ + "Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version" + ], + "matchPackageNames": ["rhysd/actionlint"], + "dependencyDashboardApproval": true + }, { "description": ["Keep MUI packages together"], "matchPackageNames": ["@mui/**"], diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml deleted file mode 100644 index a4902d15..00000000 --- a/.github/workflows/ci-terraform.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: Terraform CI - -# Static checks only. Plans run in HCP Terraform as speculative VCS runs on -# the PR (shoc-frontend-new-dev and shoc-frontend-new-staging). Applies are -# HCP auto-apply on merge to main (dev) and on a vX.Y.Z-staging tag (staging). - -on: - pull_request: - branches: [main, dev] - paths: - - "terraform/**" - - "scripts/**" - - ".github/workflows/ci-terraform.yaml" - - ".github/workflows/deploy-web.yaml" - push: - branches: [main] - paths: - - "terraform/**" - - "scripts/**" - - ".github/workflows/ci-terraform.yaml" - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - terraform_wrapper: false - - - name: Terraform fmt - run: terraform fmt -check -recursive terraform - - - name: Validate live/dev - run: | - terraform -chdir=terraform/live/dev init -backend=false -input=false -lockfile=readonly -no-color - terraform -chdir=terraform/live/dev validate -no-color - - - name: Validate live/staging - run: | - terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color - terraform -chdir=terraform/live/staging validate -no-color - - - name: Import plan guard tests - run: python3 scripts/test-terraform-import-plan-check.py - - - name: App/Terraform isolation tests - run: python3 scripts/test_check_app_terraform_isolation.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0409abf0..1b9935e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,6 +3,9 @@ name: Frontend checks on: pull_request: branches: [main, dev] + # The merge queue builds main plus the queued pull requests on a temporary + # branch and only counts checks that ran on the merge_group event. + merge_group: push: branches: [main] workflow_dispatch: {} @@ -10,28 +13,109 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + 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" + static: + name: static + runs-on: ubuntu-latest + 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 format:check + - run: npm run lint + + build: + name: build + runs-on: ubuntu-latest + 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 build + + unit: + name: unit + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + 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 test -- --shard=${{ matrix.shard }}/4 + + visual: + 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 + + browser-smoke: + name: browser-smoke + 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 + - name: Upload smoke artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: browser-smoke-artifacts + path: | + test-results + playwright-report + if-no-files-found: ignore + retention-days: 14 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 - # guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13 - # app/Terraform isolation). Runs on PRs to main or dev; push is main only. - # If the reusable workflow is later confirmed to run every gate, this job - # can be slimmed to `npm run governance`. + # Repo-owned gates: godfile ratchet, changed-file maintainability, + # Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront + # verify, GitHub workflow shell, isolation classifier tests, and live G13 + # (pull_request, merge_group per queued PR, and local). Format, lint, build, + # and unit tests run + # in the parallel jobs above, not here. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) + # merge group -> the group's own base (github.event.merge_group.base_sha) # push-> the previous commit on the branch (github.event.before) # manual -> main, for exact-head recovery runs runs-on: ubuntu-latest @@ -46,10 +130,13 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_BEFORE: ${{ github.event.before }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: | set -euo pipefail if [[ "${EVENT_NAME}" == "pull_request" ]]; then base="${PR_BASE_SHA}" + elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then + base="${MERGE_GROUP_BASE_SHA}" elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then base="${EVENT_BEFORE}" else @@ -79,29 +166,29 @@ jobs: tar -xzf actionlint.tar.gz actionlint sudo mv actionlint /usr/local/bin/actionlint - run: npm ci - - run: npm run verify + - run: npm run governance env: GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} - visual-regression: - name: Visual regression + ci-complete: + name: ci-complete + if: always() + needs: [static, build, unit, visual, browser-smoke, governance] 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 + - name: All required jobs passed + shell: bash + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + failed=0 + for result in ${RESULTS}; do + if [[ "${result}" != "success" ]]; then + failed=1 + fi + done + if [[ "${failed}" -ne 0 ]]; then + echo "Required jobs did not all succeed: ${RESULTS}" + exit 1 + fi diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 943cad51..aaa1ac1e 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -28,7 +28,6 @@ on: - "docs/**" - "**/*.md" - ".github/workflows/ci.yaml" - - ".github/workflows/ci-terraform.yaml" - ".github/workflows/deploy-web.yaml" release: types: [published] diff --git a/.gitignore b/.gitignore index 9536adc7..e3a043a2 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ seed-data.sql # python __pycache__/ *.py[cod] + +# local scratch +/tmp/ diff --git a/AGENTS.md b/AGENTS.md index 68229d76..e774bafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,9 @@ This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScrip build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance checks (`npm run governance`), including G13 app/Terraform isolation. **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. +gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) +(`static`, `build`, `unit`, `visual`, `browser-smoke`, `governance`) with +`ci-complete` failing if any of those jobs did not succeed. ## Non-negotiable rules (enforced; do not work around) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 75693239..bbfbdf1c 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -14,24 +14,24 @@ isolation). 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 formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | -| 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 | -| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` | +| 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 formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | +| 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 | +| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) | ## No-false-pass guarantees @@ -60,15 +60,18 @@ isolation). A task is not done until this is green. - **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 CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):** - fmt, `init -backend=false`, validate, import-plan unit tests, and G13 - classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`. +- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this + repository owns every job. `static` (`format:check` + `lint`), `build` + (`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual` + (Playwright visual), `browser-smoke` (`npm run test:e2e`), and `governance` + (`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete` + fails unless all of those jobs succeeded and is the required merge-queue + check. Live G13 runs on + `pull_request` (merge-base range), `merge_group` (each queued PR as a + first-parent commit), and locally. It skips `push`. A Terraform-only PR + stacked with an app-only PR still passes; a mixed change set still fails. + Terraform fmt/validate and the related unit tests run inside `governance` on + every event. ## Toolchain pin diff --git a/README.md b/README.md index 7e2acaa2..3ee3c5f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SHOC Frontend (`shoc-frontend-new`) -[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) +[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) [![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) @@ -13,7 +13,8 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). - **GitHub:** `Sea-Haven-Industries/shoc-frontend-new` -- **Hosted at:** (dev environment; the only environment today) +- **Hosted at:** (dev, deployed from `main`) and + (staging, deployed from `vX.Y.Z-staging` tags) - **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend` ## Architecture @@ -122,19 +123,23 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or ## Contributing -- Branch from `dev` with a kebab-case description and a prefix matching the - work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/` +- Branch from `main` with a kebab-case description and a prefix matching the + work: `feature/`, `fix/`, `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 `main`. Protected branches need 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. -- PRs cannot mix `terraform/` with deployable application files (G13). Workflow, - docs, and gate-script changes may travel with either side. `deploy-web.yaml` - still ignores `terraform/**` so a Terraform-only merge does not sync the bucket. +- Open PRs against `main`. The PR body uses the three-section layout the + template pre-fills: Summary, Changes and value, Ticket. `main` needs the + `ci-complete` check and an approving review from a code owner + (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale + approvals. PRs merge through the merge queue, so a branch does not need to + be updated with `main` before it merges. Merged branches are deleted + automatically. +- A change set cannot mix `terraform/` with deployable application files (G13), + including each queued PR on the merge-group check. Workflow, docs, and + gate-script changes may travel with either side. `deploy-web.yaml` still + ignores `terraform/**` so a Terraform-only merge does not sync the bucket. - Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts `vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a prod distribution exists. @@ -144,19 +149,13 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or No stored AWS keys — OIDC only. Infrastructure and content deploy separately: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - and PRs to `main`, 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) - and the Terraform gates) is guaranteed from this repository. Conventions - and gates are documented under + and PRs to `main`, runs format, lint, build, sharded unit tests, visual + regression, Playwright smoke, and `npm run governance` as parallel jobs, then + `ci-complete`. + 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 CI** - ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)) - — fmt, `init -backend=false`, validate, and import-plan tests. - **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml)) — push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys `staging`. Syncs `dist/` to the bucket root and invalidates `/*`. diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md index 911e8042..fbf5eccf 100644 --- a/REVIEW_AND_PR_FRAMEWORK.md +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`, `public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`) fails G13. Workflow, docs, and gate-script changes may travel with either side. + +## 8. PR description contract (minimal) + +- **Summary** — what changed and why, in plain language. +- **Changes and value** — grouped by area, each with the value it delivers. +- **Ticket** — the board key(s) when one applies; "None" otherwise. +- Link any ADR relied upon. + +Avoid boilerplate: no deployment notes, no validation transcripts, no +"residual-risk" theatre, no AI signatures. The validation story lives in the +check run results and the close-out, not in the PR body. + +`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org +template, whose Summary / Validation / Tests / Notes headings this repository +does not use. The org `callable-pr-policy` workflow hard-codes those four +headings; it is not wired into this repository, and this layout is the reason. diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 67a84215..a078ccf6 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index d95ef194..0db529fe 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index f3a5e057..f4feaee6 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 6487de7d..1b239e83 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 36602c27..310f2ce5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 80bcdb86..6742d5c8 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index f724fe2d..bc920e8d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index 8a7a6caf..cffc357d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index 3fcea96e..dba22877 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index ca5acc44..c921fc40 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index 8fb56c7f..73995d8f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index c046b9c5..c5f9d6e9 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png index 5311388e..1377fbcb 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 35e6b135..121879f8 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 13c7b1eb..57d5671b 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -78,49 +78,54 @@ test.describe("Vendor operations", () => { .toBeGreaterThanOrEqual(280); }); - test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({ + test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({ page, }) => { await authenticate(page); - await page.route("**/api/vendor-operations/notifications", (route) => + const workOrder = { kind: "workOrder", tab: "info" }; + await page.route("**/api/notifications", (route) => fulfillJson(route, { - items: [ + generatedAt: "2026-07-23T12:00:00Z", + sections: [ { - id: "no-vendor-501", - type: "NoVendor", + reason: "no-vendor", + label: "No Vendor", 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: [ + count: 1, + items: [ { - 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", + id: "no-vendor-501", + reason: "no-vendor", + severity: "Medium", + rowType: "dismissable", + title: "WO #WO-501 starts within 48h with no vendor assigned", + count: 1, + triggeredAt: "2026-07-23T12:00:00Z", + target: { ...workOrder, workOrderId: 501 }, + workOrders: [{ id: 501, number: "WO-501" }], }, ], - actions: [ - { label: "WO-501", href: "/workorders/501" }, - { label: "WO-502", href: "/workorders/502" }, + }, + { + reason: "vendor-conflict", + label: "Vendor Conflict", + severity: "Medium", + count: 2, + items: [ + { + id: "vendor-conflict-501-502", + reason: "vendor-conflict", + severity: "Medium", + rowType: "dismissable", + title: "Acme has overlapping appointments", + count: 2, + triggeredAt: "2026-07-23T12:00:00Z", + target: { ...workOrder, workOrderId: 501 }, + workOrders: [ + { id: 501, number: "WO-501" }, + { id: 502, number: "WO-502" }, + ], + }, ], }, ], @@ -129,21 +134,19 @@ test.describe("Vendor operations", () => { 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( + await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute( "href", "/workorders/501", ); - await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute( + await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute( "href", "/workorders/502", ); + + await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click(); + await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/); }); test("filters insights and exposes both export formats", async ({ page }) => { diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts index a8fde88f..f778567b 100644 --- a/e2e/work-orders/completion-uploads-mobile.spec.ts +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -50,6 +50,10 @@ function multipartBoundary(request: Request): string | undefined { return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; } +function multipartFileName(body: string): string | undefined { + return /filename="([^"]+)"/.exec(body)?.[1]; +} + async function hideQueryDevtools(page: Page) { await page.addStyleTag({ content: @@ -141,7 +145,7 @@ test.describe("Completion uploads on a mobile viewport", () => { 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 fileName = multipartFileName(body) ?? "file"; const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; media.push(item); return fulfillJson(route, item); @@ -156,18 +160,18 @@ test.describe("Completion uploads on a mobile viewport", () => { 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([ + const files = [ { 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 (await chooser).setFiles(files); - 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 expect.poll(() => uploads.length).toBe(files.length); + for (const { name } of files) { + const upload = uploads.find((item) => multipartFileName(item.body) === name); + expect(upload, `multipart upload for ${name}`).toBeTruthy(); + expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/); } await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before"); diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts new file mode 100644 index 00000000..bcac5250 --- /dev/null +++ b/e2e/work-orders/inline-technician.spec.ts @@ -0,0 +1,265 @@ +import { expect, test, type Page, type Request, type Route } from "@playwright/test"; + +// Technicians registered inline from work-order entry points +// become real vendor records only on save, through the additive vendor-company roster +// PATCH (never a whole-vendor POST that restates or blanks company contact fields). + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-inline-technician-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 VENDORS = [ + { + id: 41, + companyName: "Vinewood LLC", + contactName: "Adam Whyte", + tradeSpecialties: "HVAC", + address: "1 Market St", + }, +]; + +const ROSTER = { + companyId: 7, + rowVersion: "AAAAAAAAB9E=", + name: "Vinewood LLC", + companyPhone: "(314) 555-0100", + email: "", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + notes: "", + technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }], +}; + +const BOARD_ROW = { + 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: 41, + vendorName: "Vinewood LLC", + techName: "Adam Whyte", + title: "Quarterly HVAC PM", + rowVersion: "rv-1", +}; + +type Captured = { method: string; pathname: string; body: unknown }; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function bodyOf(request: Request): unknown { + try { + return request.postDataJSON(); + } catch { + return undefined; + } +} + +function rosterAfterAdd(body: { addTechnicians?: Array> }) { + const added = (body.addTechnicians ?? []).map((technician, index) => ({ + ...technician, + id: 42 + index, + })); + return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] }; +} + +async function mockApi(page: Page, writes: Captured[]) { + 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.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/")) return route.fallback(); + const method = request.method(); + if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) }); + + if (pathname.includes("/workorders/board/search")) { + return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }); + } + if (pathname === "/api/workorders/board" && method === "GET") { + return fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [BOARD_ROW], + }); + } + if (pathname === "/api/workorders/board" && method === "POST") { + return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } }); + } + if (pathname === "/api/workorders/1/board" && method === "PATCH") { + return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" }); + } + if (pathname === "/api/vendor-company-roster" && method === "GET") { + return fulfillJson(route, ROSTER); + } + if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") { + return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never)); + } + if (pathname.toLowerCase().includes("/vendors/dropdown")) { + return fulfillJson(route, { data: VENDORS }); + } + if (pathname.includes("/locations/sites")) { + return fulfillJson(route, { + data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }], + }); + } + if (pathname === "/api/locations/10") { + return fulfillJson(route, { + data: { + id: 10, + name: "St. Louis HQ", + siteCode: "STL-01", + contact: "Pat Site", + phone: "3145550111", + }, + }); + } + return fulfillJson(route, { data: [] }); + }); +} + +function vendorWrites(writes: Captured[]) { + return writes.filter( + (write) => + write.pathname.startsWith("/api/vendors") || + write.pathname.startsWith("/api/vendor-company-roster"), + ); +} + +async function openWizardVendorStep(page: Page) { + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /select site/i }).click(); + await page + .getByRole("button", { name: /STL-01/ }) + .last() + .click(); + await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /select company/i }).click(); + await page.getByRole("button", { name: "Vinewood LLC" }).last().click(); + await page.getByRole("button", { name: "+ Add technician" }).click(); + await page.getByLabel("New Technician 1 name").fill("Jordan Lee"); + await page.getByLabel("New Technician 1 phone").fill("3145550199"); +} + +test.describe("Inline technician registration", () => { + test("wizard persists the technician under the company on create and assigns it", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await openWizardVendorStep(page); + + // Staging a technician is local only. + expect(vendorWrites(writes)).toEqual([]); + + await page.getByRole("button", { name: "Create work order" }).click(); + + await expect + .poll(() => writes.find((write) => write.pathname === "/api/workorders/board")) + .toBeTruthy(); + expect(vendorWrites(writes)).toEqual([ + { + method: "PATCH", + pathname: "/api/vendor-company-roster/7", + body: { + rowVersion: "AAAAAAAAB9E=", + addTechnicians: [ + { + contactName: "Jordan Lee", + phone: "(314) 555-0199", + email: "", + tradeSpecialties: "HVAC", + isActive: true, + preferredContact: "Phone", + }, + ], + }, + }, + ]); + const create = writes.find((write) => write.pathname === "/api/workorders/board"); + expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 })); + }); + + test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await openWizardVendorStep(page); + + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden(); + + // Reopening starts clean: the staged technician was discarded, not persisted. + await page.getByRole("button", { name: "New WO" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /select site/i }).click(); + await page + .getByRole("button", { name: /STL-01/ }) + .last() + .click(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0); + + expect(writes).toEqual([]); + }); + + test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await page.goto("/workorders"); + + await page + .locator("#wo-row-1") + .getByRole("button", { name: /Adam Whyte/ }) + .click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /\+ add technician/i }).click(); + await dialog.getByPlaceholder("Full name").fill("Jordan Lee"); + await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199"); + await dialog.getByRole("button", { name: /^add$/i }).click(); + await dialog.getByRole("button", { name: /^save$/i }).click(); + + await expect(dialog).toBeHidden(); + await expect + .poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board")) + .toBeTruthy(); + expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false); + const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7"); + expect(rosterPatch?.body).not.toHaveProperty("companyFields"); + const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board"); + expect(JSON.stringify(boardPatch?.body)).toContain("42"); + }); +}); diff --git a/e2e/work-orders/wizard-vendor-company-notes.spec.ts b/e2e/work-orders/wizard-vendor-company-notes.spec.ts index df507616..7006302b 100644 --- a/e2e/work-orders/wizard-vendor-company-notes.spec.ts +++ b/e2e/work-orders/wizard-vendor-company-notes.spec.ts @@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => { await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /Select company/ }).click(); + await page + .getByRole("button", { name: /Gateway Plumbing/ }) + .first() + .click(); + await page.getByRole("button", { name: /Select technician/ }).click(); await page .getByRole("button", { name: /Adam Whyte/ }) diff --git a/package.json b/package.json index 3ce507f5..b1fc3700 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "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", - "test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py", + "test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/playwright.config.ts b/playwright.config.ts index c37b71ed..66715338 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? "github" : "list", use: { // Port 4173 isolates Playwright from the app's normal port 3000 server. diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index f442c886..1d89a168 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: false, forbidOnly: true, retries: 0, - workers: 1, + workers: 2, reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]], outputDir: "test-results/visual", snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", diff --git a/scripts/g13-live-isolation.mjs b/scripts/g13-live-isolation.mjs new file mode 100644 index 00000000..3e2d8f65 --- /dev/null +++ b/scripts/g13-live-isolation.mjs @@ -0,0 +1,29 @@ +/** + * Live G13 scheduling and how a GitHub event is split into change sets. + * + * Isolation is a per-change rule. pull_request and local runs classify the + * merge-base...HEAD range (one PR). merge_group classifies each first-parent + * commit vs its parent (one queued PR per squash or merge-commit). The group + * union is not a change set: a Terraform-only PR stacked with an app-only PR + * must still pass. push is not classified; landing already happened. + */ + +export function shouldRunLiveIsolation(eventName) { + return !eventName || eventName === "pull_request" || eventName === "merge_group"; +} + +export function usesPerCommitIsolation(eventName) { + return eventName === "merge_group"; +} + +/** + * File lists to run through check_app_terraform_isolation.py. + * `commitDiffs` is first-parent order (oldest first); ignored except on + * merge_group. + */ +export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) { + if (usesPerCommitIsolation(eventName)) { + return commitDiffs.map((commit) => commit.files); + } + return [rangeFiles]; +} diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 46172487..025489cb 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs"; + 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"); @@ -218,15 +220,79 @@ function runRepositoryGate(label, script) { return { label, status: result.status, error: result.error }; } -function runIsolationGate(baseRef) { - const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]); +function classifyIsolationPaths(files) { return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { cwd: ROOT, encoding: "utf8", - input: `${files.join("\n")}\n`, + input: files.length > 0 ? `${files.join("\n")}\n` : "", }); } +function firstParentCommitDiffs(baseRef) { + const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]); + return shas.map((sha) => ({ + sha, + files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]), + })); +} + +function runIsolationGate(baseRef, eventName) { + if (usesPerCommitIsolation(eventName)) { + const commits = firstParentCommitDiffs(baseRef); + return { + mode: "per-commit", + results: commits.map((commit) => ({ + ...classifyIsolationPaths(commit.files), + sha: commit.sha, + })), + }; + } + // Diff from the merge base, not the moving base tip. A two-dot diff against + // a branch that has advanced reports everything the base gained after the + // branch point as if this change reverted it. + const mergeBase = gitText(["merge-base", baseRef, "HEAD"]); + const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]); + return { + mode: "range", + mergeBase, + results: [{ ...classifyIsolationPaths(files), sha: null }], + }; +} + +function recordIsolationFailures(isolation, failures) { + const startError = isolation.results.find((result) => result.error); + if (startError) { + failures.push(`G13: could not start: ${startError.error.message}`); + } + if (isolation.results.some((result) => !result.error && result.status !== 0)) { + failures.push("G13: do not mix deployable application files with terraform/"); + } +} + +function logIsolationGate(baseRef, isolation) { + if (isolation.mode === "per-commit") { + console.log( + `G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`, + ); + for (const result of isolation.results) { + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + const prefix = result.sha ? result.sha.slice(0, 7) : "commit"; + if (output) { + console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`); + } + } + return; + } + const mergeBase = isolation.mergeBase ?? "unresolvable"; + console.log( + `G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`, + ); + const result = isolation.results[0]; + if (!result) return; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); +} + function main() { const failures = []; const baseRef = resolveBaseRef(); @@ -326,23 +392,30 @@ function main() { } console.log("─".repeat(64)); - console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`); - if (!baseRef) { + const eventName = process.env.GITHUB_EVENT_NAME; + if (!shouldRunLiveIsolation(eventName)) { + console.log( + `G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`, + ); + } else if (!baseRef) { + console.log("G13: application and Terraform isolation (no base...HEAD)"); console.log(" FAIL (no valid base ref)"); failures.push( "G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.", ); } else { - const isolation = runIsolationGate(baseRef); - if (isolation.error) { - console.log(" FAIL (could not start)"); - failures.push(`G13: could not start: ${isolation.error.message}`); - } else { - const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim(); - if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); - if (isolation.status !== 0) { - failures.push("G13: do not mix deployable application files with terraform/"); - } + let isolation; + try { + isolation = runIsolationGate(baseRef, eventName); + } catch (error) { + console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`); + console.log(" FAIL (could not resolve isolation diffs)"); + const message = error instanceof Error ? error.message : String(error); + failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`); + } + if (isolation) { + logIsolationGate(baseRef, isolation); + recordIsolationFailures(isolation, failures); } } diff --git a/scripts/test-g13-live-isolation.mjs b/scripts/test-g13-live-isolation.mjs new file mode 100644 index 00000000..5e402e4c --- /dev/null +++ b/scripts/test-g13-live-isolation.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + liveIsolationFileSets, + shouldRunLiveIsolation, + usesPerCommitIsolation, +} from "./g13-live-isolation.mjs"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py"); +const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"]; +const APP_ONLY = ["src/app/routes.tsx"]; + +function classify(files) { + const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" }); + return result.status; +} + +function anySetFails(fileSets) { + return fileSets.some((files) => classify(files) !== 0); +} + +describe("shouldRunLiveIsolation", () => { + it("runs locally and on pull_request", () => { + assert.equal(shouldRunLiveIsolation(undefined), true); + assert.equal(shouldRunLiveIsolation(""), true); + assert.equal(shouldRunLiveIsolation("pull_request"), true); + }); + + it("runs on merge_group so the required check classifies the candidate", () => { + assert.equal(shouldRunLiveIsolation("merge_group"), true); + assert.equal(usesPerCommitIsolation("merge_group"), true); + }); + + it("skips push and other CI events", () => { + assert.equal(shouldRunLiveIsolation("push"), false); + assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false); + assert.equal(usesPerCommitIsolation("pull_request"), false); + }); +}); + +describe("liveIsolationFileSets", () => { + it("classifies the PR range as one change set", () => { + const range = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("pull_request", range, [ + { files: TERRAFORM_ONLY }, + { files: APP_ONLY }, + ]); + assert.deepEqual(sets, [range]); + assert.equal(anySetFails(sets), true); + }); + + it("classifies each queued PR, not the merge-group union", () => { + const union = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("merge_group", union, [ + { files: TERRAFORM_ONLY }, + { files: APP_ONLY }, + ]); + assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]); + assert.equal(anySetFails(sets), false); + assert.equal(classify(union), 1); + }); + + it("fails a single queued PR that mixes terraform and app files", () => { + const mixed = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]); + assert.equal(anySetFails(sets), true); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 64405aa6..b5da6165 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -30,7 +30,9 @@ export const API_PATHS = { boardCreate: "workorders/board", boardSearch: "workorders/board/search", boardPatch: (id: string | number) => `workorders/${id}/board`, + pocPatch: (id: string | number) => `workorders/${id}/poc`, boardDetail: (id: string | number) => `workorders/${id}/detail`, + completionTemplates: "workorders/completion-templates", comments: (id: string | number) => `workorders/${id}/comments`, commentItem: (workOrderId: string | number, commentId: string | number) => `workorders/${workOrderId}/comments/${commentId}`, @@ -167,6 +169,8 @@ export const API_PATHS = { }, services: { list: "services", + byId: (id: string | number) => `services/${id}`, + deactivate: (id: string | number) => `services/${id}/deactivate`, }, user: { list: "User", diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index eca5363c..a5826b19 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; export default function AdminShellLayout() { const theme = useTheme(); @@ -25,29 +26,31 @@ export default function AdminShellLayout() { return ( - - setMobileNavOpen(false)} - /> - - - + + + setMobileNavOpen(false)} + /> - + + + + + - + ); } diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx index c4e0b75e..a361fc38 100644 --- a/src/app/(protected)/notifications/index.tsx +++ b/src/app/(protected)/notifications/index.tsx @@ -1,104 +1,26 @@ -import { useQuery } from "@tanstack/react-query"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Paper, - Stack, - Typography, -} from "@mui/material"; -import { Link } from "react-router"; -import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; +import { Box } from "@mui/material"; +import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions"; +import { NotificationFeedList } from "@/components/notifications/notification-feed-list"; +import { useNotificationCenter } from "@/components/notifications/use-notification-center"; +import { PageHeader } from "@/components/ui/page-header"; export default function NotificationsPage() { - const { - data = [], - isLoading, - error, - } = useQuery({ - queryKey: ["vendor-operations", "notifications"], - queryFn: vendorOperationsApi.notifications, - refetchInterval: 30_000, - }); - - const groups = data.reduce((result, item) => { - const items = result.get(item.group) ?? []; - items.push(item); - result.set(item.group, items); - return result; - }, new Map()); + const center = useNotificationCenter(); return ( - - - - Items requiring attention - - - Medium-severity vendor reminders refresh automatically. They do not create banners or - toasts. - + + + + + } + /> + + - {isLoading && } - {Boolean(error) && {error?.message}} - {!isLoading && !error && data.length === 0 && ( - - No vendor alerts require attention. - - )} - {[...groups.entries()].map(([group, items]) => ( - - - {group} - - - - {items.map((item) => ( - - - - - - {item.title} - - - {item.workOrderNumber ?? - item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} - - {item.scheduledAt != null && ( - - Scheduled {new Date(item.scheduledAt).toLocaleString()} - - )} - - - {item.action != null && ( - - )} - {item.actions?.map((action) => ( - - ))} - - - - ))} - - - ))} ); } diff --git a/src/app/(protected)/settings/_components/services-registry-components.tsx b/src/app/(protected)/settings/_components/services-registry-components.tsx new file mode 100644 index 00000000..e7145bec --- /dev/null +++ b/src/app/(protected)/settings/_components/services-registry-components.tsx @@ -0,0 +1,379 @@ +import { + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + Drawer, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react"; +import { + SERVICE_WORK_ORDER_TYPES, + type CompletionDocTemplateOption, + type Service, + type ServiceWorkOrderType, +} from "@/domain/services/types/service"; +import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller"; + +const ICONS = [ + { key: "wrench", label: "Wrench", Icon: Wrench }, + { key: "droplets", label: "Droplets", Icon: Droplets }, + { key: "zap", label: "Zap", Icon: Zap }, + { key: "hammer", label: "Hammer", Icon: Hammer }, + { key: "settings", label: "Settings", Icon: Settings2 }, +] as const; + +export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) { + const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0]; + const Icon = icon.Icon; + return