chore: harden reliability checks #2
@ -13,26 +13,34 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12.0
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 BACKEND TESTS
|
# Verification gate
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Install backend dependencies
|
- name: Install dependencies
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm ci
|
npm ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run reliability verification
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm test --if-present
|
npm run audit
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run db:migrate:stale:check
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Docker Login
|
# Docker Login
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Docker login
|
- name: Docker login
|
||||||
run: |
|
run: |
|
||||||
@ -40,7 +48,7 @@ jobs:
|
|||||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Backend Image
|
# Build Backend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Backend Image
|
- name: Build Backend Image
|
||||||
run: |
|
run: |
|
||||||
@ -55,7 +63,7 @@ jobs:
|
|||||||
docker push $REGISTRY/backend:latest
|
docker push $REGISTRY/backend:latest
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Frontend Image
|
# Build Frontend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Frontend Image
|
- name: Build Frontend Image
|
||||||
run: |
|
run: |
|
||||||
@ -75,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install SSH key
|
- name: Install SSH key
|
||||||
run: |
|
run: |
|
||||||
@ -117,9 +125,9 @@ jobs:
|
|||||||
echo "Deployment job finished with status: $STATUS"
|
echo "Deployment job finished with status: $STATUS"
|
||||||
|
|
||||||
if [ "$STATUS" = "success" ]; then
|
if [ "$STATUS" = "success" ]; then
|
||||||
MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||||
else
|
else
|
||||||
MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -d "$MSG" \
|
curl -d "$MSG" \
|
||||||
|
|||||||
@ -15,26 +15,34 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12.0
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 BACKEND TESTS
|
# Verification gate
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Install backend dependencies
|
- name: Install dependencies
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm ci
|
npm ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run reliability verification
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm test --if-present
|
npm run audit
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run db:migrate:stale:check
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Docker Login
|
# Docker Login
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Docker login
|
- name: Docker login
|
||||||
run: |
|
run: |
|
||||||
@ -42,7 +50,7 @@ jobs:
|
|||||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Backend Image
|
# Build Backend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Backend Image
|
- name: Build Backend Image
|
||||||
run: |
|
run: |
|
||||||
@ -57,7 +65,7 @@ jobs:
|
|||||||
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Frontend Image
|
# Build Frontend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Frontend Image
|
- name: Build Frontend Image
|
||||||
run: |
|
run: |
|
||||||
@ -97,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install SSH key
|
- name: Install SSH key
|
||||||
run: |
|
run: |
|
||||||
@ -139,9 +147,9 @@ jobs:
|
|||||||
echo "Deployment job finished with status: $STATUS"
|
echo "Deployment job finished with status: $STATUS"
|
||||||
|
|
||||||
if [ "$STATUS" = "success" ]; then
|
if [ "$STATUS" = "success" ]; then
|
||||||
MSG="🚀 Grocery App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Grocery App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||||
else
|
else
|
||||||
MSG="❌ Grocery App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -d "$MSG" \
|
curl -d "$MSG" \
|
||||||
|
|||||||
@ -193,6 +193,7 @@ Usage rules:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 12) Commit Discipline (required)
|
## 12) Commit Discipline (required)
|
||||||
|
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
||||||
- Commit in small, logical slices (no broad mixed-purpose commits).
|
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||||
- Each commit must:
|
- Each commit must:
|
||||||
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
||||||
@ -200,4 +201,5 @@ Usage rules:
|
|||||||
- exclude secrets, credentials, and generated noise
|
- exclude secrets, credentials, and generated noise
|
||||||
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||||
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||||
|
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
||||||
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
# Database Migration: Add Image Support
|
|
||||||
|
|
||||||
Run these SQL commands on your PostgreSQL database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Add image columns to grocery_list table
|
-- Add image columns to grocery_list table
|
||||||
ALTER TABLE grocery_list
|
ALTER TABLE grocery_list
|
||||||
ADD COLUMN item_image BYTEA,
|
ADD COLUMN IF NOT EXISTS item_image BYTEA,
|
||||||
ADD COLUMN image_mime_type VARCHAR(50);
|
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
|
||||||
|
|
||||||
-- Optional: Add index for faster queries when filtering by items with images
|
-- Index to speed up queries that filter by rows with images.
|
||||||
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
|
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
|
||||||
```
|
ON grocery_list ((item_image IS NOT NULL));
|
||||||
|
|
||||||
## To Verify:
|
|
||||||
```sql
|
|
||||||
\d grocery_list
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see the new columns `item_image` and `image_mime_type`.
|
|
||||||
|
|||||||
@ -1,65 +1,99 @@
|
|||||||
{
|
{
|
||||||
"generated_at": "2026-02-19T07:24:39.402Z",
|
"generated_at": "2026-05-25T23:06:21.741Z",
|
||||||
"canonical_dir": "packages\\db\\migrations",
|
"canonical_dir": "packages\\db\\migrations",
|
||||||
"legacy_dir": "backend\\migrations",
|
"legacy_dir": "backend\\migrations",
|
||||||
"stale_sql_files": [
|
"stale_sql_files": [
|
||||||
{
|
{
|
||||||
"filename": "add_display_name_column.sql",
|
"filename": "add_display_name_column.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||||
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||||
|
"normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "add_image_columns.sql",
|
"filename": "add_image_columns.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
"backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a",
|
"requires_action": false,
|
||||||
"canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a"
|
"backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||||
|
"canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||||
|
"normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "add_modified_on_column.sql",
|
"filename": "add_modified_on_column.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||||
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b"
|
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||||
|
"normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "add_notes_column.sql",
|
"filename": "add_notes_column.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||||
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||||
|
"normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "create_item_classification_table.sql",
|
"filename": "create_item_classification_table.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||||
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a"
|
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||||
|
"normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "multi_household_architecture.sql",
|
"filename": "multi_household_architecture.sql",
|
||||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||||
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||||
|
"normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"canonical_only_sql_files": [
|
"canonical_only_sql_files": [
|
||||||
|
{
|
||||||
|
"filename": "20260328_010000_add_household_store_available_items.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "58eaf6b526e0317edd45083ba64432fb973ab4a489c0bfd320c422ee501a6206"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "20260329_010000_add_household_store_items.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "4421515183150c388b19dde66e682807269fbc31414cc1ccfc095abab3788188"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "20260329_020000_fix_household_item_classification_upsert.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "8c86cde57bf98b0c9bf5340d685150e89a2fdb873d1bda83893506b2b2478e62"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename": "create_sessions_table.sql",
|
"filename": "create_sessions_table.sql",
|
||||||
"status": "CANONICAL_ONLY",
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
|
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "zz_group_invites_and_join_policies.sql",
|
"filename": "zz_group_invites_and_join_policies.sql",
|
||||||
"status": "CANONICAL_ONLY",
|
"status": "CANONICAL_ONLY",
|
||||||
"canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3"
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"legacy_non_sql_files": [
|
"legacy_non_sql_files": [
|
||||||
"MIGRATION_GUIDE.md"
|
"MIGRATION_GUIDE.md",
|
||||||
|
"stale-sql-report.json"
|
||||||
],
|
],
|
||||||
"summary": {
|
"summary": {
|
||||||
"stale_total": 6,
|
"stale_total": 6,
|
||||||
"stale_only_in_backend_total": 0,
|
"stale_only_in_backend_total": 0,
|
||||||
"stale_duplicate_total": 6,
|
"stale_duplicate_total": 6,
|
||||||
"stale_diverged_total": 0,
|
"stale_diverged_total": 0,
|
||||||
"canonical_only_total": 2
|
"action_required_total": 0,
|
||||||
|
"canonical_only_total": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,13 +20,16 @@ This project uses an external on-prem Postgres database. Migration files are can
|
|||||||
- `npm run db:migrate:new -- <migration-name>`
|
- `npm run db:migrate:new -- <migration-name>`
|
||||||
- Track stale legacy SQL in `backend/migrations`:
|
- Track stale legacy SQL in `backend/migrations`:
|
||||||
- `npm run db:migrate:stale`
|
- `npm run db:migrate:stale`
|
||||||
- Fail when stale legacy SQL exists:
|
- Fail when legacy SQL needs operator attention:
|
||||||
- `npm run db:migrate:stale:check`
|
- `npm run db:migrate:stale:check`
|
||||||
|
|
||||||
## Active migration set
|
## Active migration set
|
||||||
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
||||||
|
|
||||||
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
||||||
|
Duplicate reference copies are reported by the stale tracker, but the check fails only
|
||||||
|
when a legacy SQL file exists only in `backend/migrations` or diverges from its
|
||||||
|
canonical file.
|
||||||
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
||||||
|
|
||||||
Current baseline files:
|
Current baseline files:
|
||||||
|
|||||||
@ -35,7 +35,7 @@ Important variables:
|
|||||||
| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. |
|
| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. |
|
||||||
| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. |
|
| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. |
|
||||||
| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. |
|
| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. |
|
||||||
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`. |
|
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`; the e2e runner starts Vite on this URL. |
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
Docker dev stack:
|
Docker dev stack:
|
||||||
@ -70,6 +70,10 @@ npm run build
|
|||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run test:e2e` uses `frontend/scripts/run-playwright.mjs` to start Vite, run
|
||||||
|
Playwright, and shut Vite down cleanly. Pass Playwright flags after `--`, for
|
||||||
|
example `npm run test:e2e -- --reporter=list --workers=1`.
|
||||||
|
|
||||||
Migration checks:
|
Migration checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -9,9 +9,9 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "node scripts/run-playwright.mjs",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "node scripts/run-playwright.mjs --headed",
|
||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "node scripts/run-playwright.mjs --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|||||||
@ -20,10 +20,4 @@ export default defineConfig({
|
|||||||
use: { browserName: "chromium", channel: "chrome" },
|
use: { browserName: "chromium", channel: "chrome" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
|
||||||
command: "npm run dev -- --host localhost --port 3010",
|
|
||||||
url: baseURL,
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
80
frontend/scripts/run-playwright.mjs
Normal file
80
frontend/scripts/run-playwright.mjs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
const baseUrl = new URL(process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010");
|
||||||
|
const port = Number(baseUrl.port || 3010);
|
||||||
|
const host = baseUrl.hostname || "localhost";
|
||||||
|
|
||||||
|
let child = null;
|
||||||
|
let closing = false;
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
server: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function closeServer() {
|
||||||
|
if (closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closing = true;
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown(signal) {
|
||||||
|
if (child && !child.killed) {
|
||||||
|
child.kill(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeServer();
|
||||||
|
} finally {
|
||||||
|
process.exit(signal === "SIGINT" || signal === "SIGTERM" ? 130 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once("SIGINT", () => {
|
||||||
|
void shutdown("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.once("SIGTERM", () => {
|
||||||
|
void shutdown("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.listen();
|
||||||
|
server.printUrls();
|
||||||
|
|
||||||
|
const playwrightCli = fileURLToPath(
|
||||||
|
new URL("../node_modules/@playwright/test/cli.js", import.meta.url)
|
||||||
|
);
|
||||||
|
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
|
||||||
|
child = spawn(process.execPath, [playwrightCli, "test", ...process.argv.slice(2)], {
|
||||||
|
cwd: frontendRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PLAYWRIGHT_BASE_URL: baseUrl.toString().replace(/\/$/, ""),
|
||||||
|
PLAYWRIGHT_SKIP_WEBSERVER: "1",
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", async (code, signal) => {
|
||||||
|
try {
|
||||||
|
await closeServer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to close Vite after Playwright run:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
@ -1,54 +1,17 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
confirmSlide,
|
||||||
return page.addInitScript(() => {
|
mockConfig,
|
||||||
localStorage.setItem("token", "test-token");
|
mockHouseholdAndStoreShell,
|
||||||
localStorage.setItem("userId", "1");
|
seedAuthStorage,
|
||||||
localStorage.setItem("role", "admin");
|
} from "./helpers/e2e";
|
||||||
localStorage.setItem("username", "catalog-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/households", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Catalog House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
|
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "catalog-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await mockHouseholdAndStoreShell(page);
|
await mockHouseholdAndStoreShell(page, {
|
||||||
|
household: { name: "Catalog House" },
|
||||||
|
});
|
||||||
|
|
||||||
let availableItems = [
|
let availableItems = [
|
||||||
{
|
{
|
||||||
@ -170,22 +133,12 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
|||||||
await expect(confirmModal).toBeVisible();
|
await expect(confirmModal).toBeVisible();
|
||||||
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
||||||
|
|
||||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
await confirmSlide(page);
|
||||||
const track = confirmModal.locator(".confirm-slide-track");
|
|
||||||
const sliderBox = await slider.boundingBox();
|
|
||||||
const trackBox = await track.boundingBox();
|
|
||||||
|
|
||||||
if (!sliderBox || !trackBox) {
|
await expect(
|
||||||
throw new Error("Confirm slide control was not measurable");
|
page.locator(".action-toast.action-toast-success").filter({ hasText: "Deleted store item" })
|
||||||
}
|
).toContainText("Deleted store item");
|
||||||
|
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
|
||||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item");
|
|
||||||
await expect(managerModal.getByText("milk")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
mockConfig,
|
||||||
|
mockHouseholdAndStoreShell,
|
||||||
|
seedAuthStorage,
|
||||||
|
} from "./helpers/e2e";
|
||||||
|
|
||||||
type MockItem = {
|
type MockItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -15,29 +20,6 @@ type MockItem = {
|
|||||||
zone: string | null;
|
zone: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "buy-modal-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeItem(
|
function makeItem(
|
||||||
id: number,
|
id: number,
|
||||||
itemName: string,
|
itemName: string,
|
||||||
@ -68,24 +50,8 @@ async function setupBuyModalRoutes(
|
|||||||
let activeItems = initialItems.map((item) => ({ ...item }));
|
let activeItems = initialItems.map((item) => ({ ...item }));
|
||||||
let recentItems: MockItem[] = [];
|
let recentItems: MockItem[] = [];
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await mockHouseholdAndStoreShell(page, {
|
||||||
await route.fulfill({
|
household: { name: "Auto Advance House" },
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Auto Advance House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
await page.route("**/households/1/members", async (route) => {
|
||||||
@ -122,7 +88,7 @@ async function setupBuyModalRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item", async (route) => {
|
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
|
|
||||||
if (request.method() === "PATCH") {
|
if (request.method() === "PATCH") {
|
||||||
@ -208,7 +174,7 @@ async function openBuyModal(page: import("@playwright/test").Page, itemName: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
|
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await setupBuyModalRoutes(page, [
|
await setupBuyModalRoutes(page, [
|
||||||
makeItem(1, "milk", 2),
|
makeItem(1, "milk", 2),
|
||||||
@ -226,7 +192,7 @@ test("buying an item advances to the next one in the current sort order", async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await setupBuyModalRoutes(page, [
|
await setupBuyModalRoutes(page, [
|
||||||
makeItem(1, "apples", 3),
|
makeItem(1, "apples", 3),
|
||||||
@ -244,7 +210,7 @@ test("buying the last item in the current order wraps to the first remaining ite
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
|
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await setupBuyModalRoutes(page, [
|
await setupBuyModalRoutes(page, [
|
||||||
makeItem(1, "alpha", 1),
|
makeItem(1, "alpha", 1),
|
||||||
@ -257,14 +223,15 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
|
|||||||
|
|
||||||
await openBuyModal(page, "bravo");
|
await openBuyModal(page, "bravo");
|
||||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||||
|
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
|
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
|
||||||
await expect(page.locator(".glist-li").filter({ hasText: "bravo" })).toContainText("x2");
|
await expect(page.locator(".glist-li").filter({ hasText: "bravo" }).first()).toContainText("x2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buying the only remaining item closes the modal", async ({ page }) => {
|
test("buying the only remaining item closes the modal", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await setupBuyModalRoutes(page, [
|
await setupBuyModalRoutes(page, [
|
||||||
makeItem(1, "solo", 1),
|
makeItem(1, "solo", 1),
|
||||||
|
|||||||
@ -1,30 +1,12 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
mockConfig,
|
||||||
return page.addInitScript(() => {
|
mockHouseholdAndStoreShell,
|
||||||
localStorage.setItem("token", "test-token");
|
seedAuthStorage,
|
||||||
localStorage.setItem("userId", "1");
|
} from "./helpers/e2e";
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "assignment-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
|
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "assignment-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
const members = [
|
const members = [
|
||||||
@ -62,24 +44,8 @@ test("assigned items render selected users and keep the picker menu outside the
|
|||||||
}> = [];
|
}> = [];
|
||||||
let addCallCount = 0;
|
let addCallCount = 0;
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await mockHouseholdAndStoreShell(page, {
|
||||||
await route.fulfill({
|
household: { name: "Assignment House" },
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Assignment House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
await page.route("**/households/1/members", async (route) => {
|
||||||
@ -189,7 +155,7 @@ test("assigned items render selected users and keep the picker menu outside the
|
|||||||
const assignModal = page.locator(".assign-item-for-modal");
|
const assignModal = page.locator(".assign-item-for-modal");
|
||||||
await expect(assignModal).toBeVisible();
|
await expect(assignModal).toBeVisible();
|
||||||
|
|
||||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
await assignModal.locator(".assign-item-for-dropdown-trigger").click();
|
||||||
|
|
||||||
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
|
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
|
||||||
await expect(portalMenu).toBeVisible();
|
await expect(portalMenu).toBeVisible();
|
||||||
@ -207,7 +173,9 @@ test("assigned items render selected users and keep the picker menu outside the
|
|||||||
expect(dropdownMetrics.scrollable).toBe(true);
|
expect(dropdownMetrics.scrollable).toBe(true);
|
||||||
|
|
||||||
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
|
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
|
||||||
|
await expect(portalMenu).toHaveCount(0);
|
||||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await expect(assignModal).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
|
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||||
@ -219,9 +187,11 @@ test("assigned items render selected users and keep the picker menu outside the
|
|||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("bananas");
|
await page.getByPlaceholder("Enter item name").fill("bananas");
|
||||||
await page.getByRole("button", { name: "Others" }).click();
|
await page.getByRole("button", { name: "Others" }).click();
|
||||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
await assignModal.locator(".assign-item-for-dropdown-trigger").click();
|
||||||
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
|
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
|
||||||
|
await expect(portalMenu).toHaveCount(0);
|
||||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await expect(assignModal).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
|
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||||
@ -229,5 +199,7 @@ test("assigned items render selected users and keep the picker menu outside the
|
|||||||
|
|
||||||
await expect(bananasRow).toContainText("Casey Client");
|
await expect(bananasRow).toContainText("Casey Client");
|
||||||
await expect(bananasRow).toContainText("Jordan Client");
|
await expect(bananasRow).toContainText("Jordan Client");
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity");
|
await expect(
|
||||||
|
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated item quantity" })
|
||||||
|
).toContainText("Updated item quantity");
|
||||||
});
|
});
|
||||||
|
|||||||
120
frontend/tests/helpers/e2e.ts
Normal file
120
frontend/tests/helpers/e2e.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
type AuthSeed = {
|
||||||
|
token?: string;
|
||||||
|
userId?: string;
|
||||||
|
role?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HouseholdSeed = {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
invite_code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreSeed = {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
location?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig = {
|
||||||
|
maxFileSizeMB: 20,
|
||||||
|
maxImageDimension: 800,
|
||||||
|
imageQuality: 85,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function seedAuthStorage(page: Page, seed: AuthSeed = {}) {
|
||||||
|
return page.addInitScript((authSeed) => {
|
||||||
|
localStorage.setItem("token", authSeed.token || "test-token");
|
||||||
|
localStorage.setItem("userId", authSeed.userId || "1");
|
||||||
|
localStorage.setItem("role", authSeed.role || "admin");
|
||||||
|
localStorage.setItem("username", authSeed.username || "test-user");
|
||||||
|
}, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mockConfig(page: Page, overrides = {}) {
|
||||||
|
await page.route("**/config", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ...defaultConfig, ...overrides }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mockHouseholdAndStoreShell(
|
||||||
|
page: Page,
|
||||||
|
options: { household?: HouseholdSeed; stores?: StoreSeed[] } = {}
|
||||||
|
) {
|
||||||
|
const household = {
|
||||||
|
id: 1,
|
||||||
|
name: "Test Household",
|
||||||
|
role: "admin",
|
||||||
|
invite_code: "ABCD1234",
|
||||||
|
...options.household,
|
||||||
|
};
|
||||||
|
const stores = options.stores || [
|
||||||
|
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.route("**/households", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([household]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/stores/household/${household.id}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(stores),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmSlide(page: Page) {
|
||||||
|
const confirmModal = page.locator(".confirm-slide-modal");
|
||||||
|
await expect(confirmModal).toBeVisible();
|
||||||
|
|
||||||
|
const slider = confirmModal.locator(".confirm-slide-handle");
|
||||||
|
const track = confirmModal.locator(".confirm-slide-track");
|
||||||
|
const sliderBox = await slider.boundingBox();
|
||||||
|
const trackBox = await track.boundingBox();
|
||||||
|
|
||||||
|
if (!sliderBox || !trackBox) {
|
||||||
|
throw new Error("Confirm slide control was not measurable");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, {
|
||||||
|
steps: 8,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectFailedApiRequests(page: Page) {
|
||||||
|
const failedRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on("requestfailed", (request) => {
|
||||||
|
const url = request.url();
|
||||||
|
if (!url.startsWith("http://localhost:5000")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = request.failure()?.errorText || "unknown failure";
|
||||||
|
failedRequests.push(`${request.method()} ${url} :: ${failure}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return failedRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectNoFailedApiRequests(failedRequests: string[]) {
|
||||||
|
expect(failedRequests).toEqual([]);
|
||||||
|
}
|
||||||
@ -1,30 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockConfig, seedAuthStorage } from "./helpers/e2e";
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "new-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("new users with no households see create and join actions instead of a loading dead-end", async ({ page }) => {
|
test("new users with no households see create and join actions instead of a loading dead-end", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "new-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await page.route("**/households", async (route) => {
|
||||||
@ -43,7 +21,7 @@ test("new users with no households see create and join actions instead of a load
|
|||||||
await expect(page.getByRole("button", { name: "Join Household", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Join Household", exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Join Household", exact: true }).click();
|
await page.getByRole("button", { name: "Join Household", exact: true }).click();
|
||||||
await expect(page.getByLabel("Invite Code or Link")).toBeVisible();
|
await expect(page.getByLabel("Invite Link")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close household dialog" }).click();
|
await page.getByRole("button", { name: "Close household dialog" }).click();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Create Household", exact: true }).click();
|
await page.getByRole("button", { name: "Create Household", exact: true }).click();
|
||||||
|
|||||||
@ -1,49 +1,14 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page, role = "admin") {
|
collectFailedApiRequests,
|
||||||
return page.addInitScript((seedRole) => {
|
confirmSlide,
|
||||||
localStorage.setItem("token", "test-token");
|
expectNoFailedApiRequests,
|
||||||
localStorage.setItem("userId", "1");
|
mockConfig,
|
||||||
localStorage.setItem("role", seedRole);
|
seedAuthStorage,
|
||||||
localStorage.setItem("username", "manager-user");
|
} from "./helpers/e2e";
|
||||||
}, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmSlide(page: import("@playwright/test").Page) {
|
|
||||||
const confirmModal = page.locator(".confirm-slide-modal");
|
|
||||||
await expect(confirmModal).toBeVisible();
|
|
||||||
|
|
||||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
|
||||||
const track = confirmModal.locator(".confirm-slide-track");
|
|
||||||
const sliderBox = await slider.boundingBox();
|
|
||||||
const trackBox = await track.boundingBox();
|
|
||||||
|
|
||||||
if (!sliderBox || !trackBox) {
|
|
||||||
throw new Error("Confirm slide control was not measurable");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => {
|
test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "manager-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await page.route("**/households", async (route) => {
|
||||||
@ -54,13 +19,13 @@ test("join household modal accepts invite links but rejects legacy invite codes"
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/invite-links/approval-token", async (route) => {
|
await page.route("**/api/invite-links/approvaltoken", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
link: {
|
link: {
|
||||||
token: "approval-token",
|
token: "approvaltoken",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
viewerStatus: null,
|
viewerStatus: null,
|
||||||
active_policy: "APPROVAL_REQUIRED",
|
active_policy: "APPROVAL_REQUIRED",
|
||||||
@ -75,17 +40,19 @@ test("join household modal accepts invite links but rejects legacy invite codes"
|
|||||||
await page.getByLabel("Invite Link").fill("HABC123");
|
await page.getByLabel("Invite Link").fill("HABC123");
|
||||||
await page.getByRole("button", { name: "Open Invite" }).click();
|
await page.getByRole("button", { name: "Open Invite" }).click();
|
||||||
|
|
||||||
await expect(page.getByText("Use a household invite link like /invite/abcd1234.")).toBeVisible();
|
await expect(page.locator(".create-join-modal .error-message")).toHaveText(
|
||||||
|
"Use a household invite link like /invite/abcd1234."
|
||||||
|
);
|
||||||
|
|
||||||
await page.getByLabel("Invite Link").fill("/invite/approval-token");
|
await page.getByLabel("Invite Link").fill("/invite/approvaltoken");
|
||||||
await page.getByRole("button", { name: "Open Invite" }).click();
|
await page.getByRole("button", { name: "Open Invite" }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/invite\/approval-token$/);
|
await expect(page).toHaveURL(/\/invite\/approvaltoken$/);
|
||||||
await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("household management shows pending invite approvals and can approve them", async ({ page }) => {
|
test("household management shows pending invite approvals and can approve them", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page, { username: "manager-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
let members = [
|
let members = [
|
||||||
@ -213,7 +180,8 @@ test("household management shows pending invite approvals and can approve them",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("household owner can transfer ownership from household settings", async ({ page }) => {
|
test("household owner can transfer ownership from household settings", async ({ page }) => {
|
||||||
await seedAuthStorage(page, "owner");
|
const failedApiRequests = collectFailedApiRequests(page);
|
||||||
|
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }];
|
let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }];
|
||||||
@ -231,8 +199,20 @@ test("household owner can transfer ownership from household settings", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
await page.route("**/households/1/members", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(members),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/members/*/role", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
if (request.method() === "PATCH") {
|
if (request.method() !== "PATCH") {
|
||||||
|
await route.fulfill({ status: 405 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = request.postDataJSON() as { role?: string };
|
const body = request.postDataJSON() as { role?: string };
|
||||||
if (body.role === "owner") {
|
if (body.role === "owner") {
|
||||||
households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }];
|
households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }];
|
||||||
@ -250,14 +230,6 @@ test("household owner can transfer ownership from household settings", async ({
|
|||||||
member: { user_id: 2, role: body.role || "member" },
|
member: { user_id: 2, role: body.role || "member" },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(members),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/groups/join-policy", async (route) => {
|
await page.route("**/api/groups/join-policy", async (route) => {
|
||||||
@ -317,4 +289,5 @@ test("household owner can transfer ownership from household settings", async ({
|
|||||||
await expect(page.getByText("👑 Owner")).toContainText("Owner");
|
await expect(page.getByText("👑 Owner")).toContainText("Owner");
|
||||||
await expect(page.getByText("🛠️ Admin")).toContainText("Admin");
|
await expect(page.getByText("🛠️ Admin")).toContainText("Admin");
|
||||||
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
|
||||||
|
expectNoFailedApiRequests(failedApiRequests);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,6 +30,16 @@ function sha256File(filePath) {
|
|||||||
return hash.digest("hex");
|
return hash.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha256Text(value) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(value);
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparableSql(filePath) {
|
||||||
|
return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").trimEnd() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
function listFiles(dirPath) {
|
function listFiles(dirPath) {
|
||||||
return fs
|
return fs
|
||||||
.readdirSync(dirPath)
|
.readdirSync(dirPath)
|
||||||
@ -48,6 +58,7 @@ function mapByNameWithHash(dirPath, names) {
|
|||||||
name,
|
name,
|
||||||
path: path.join(dirPath, name),
|
path: path.join(dirPath, name),
|
||||||
sha256: sha256File(path.join(dirPath, name)),
|
sha256: sha256File(path.join(dirPath, name)),
|
||||||
|
normalized_sha256: sha256Text(comparableSql(path.join(dirPath, name))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@ -75,17 +86,20 @@ function buildReport() {
|
|||||||
staleFiles.push({
|
staleFiles.push({
|
||||||
filename: legacyName,
|
filename: legacyName,
|
||||||
status: "STALE_ONLY_IN_BACKEND",
|
status: "STALE_ONLY_IN_BACKEND",
|
||||||
|
requires_action: true,
|
||||||
backend_sha256: legacyFile.sha256,
|
backend_sha256: legacyFile.sha256,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legacyFile.sha256 === canonicalFile.sha256) {
|
if (legacyFile.normalized_sha256 === canonicalFile.normalized_sha256) {
|
||||||
staleFiles.push({
|
staleFiles.push({
|
||||||
filename: legacyName,
|
filename: legacyName,
|
||||||
status: "STALE_DUPLICATE_OF_CANONICAL",
|
status: "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
requires_action: false,
|
||||||
backend_sha256: legacyFile.sha256,
|
backend_sha256: legacyFile.sha256,
|
||||||
canonical_sha256: canonicalFile.sha256,
|
canonical_sha256: canonicalFile.sha256,
|
||||||
|
normalized_sha256: legacyFile.normalized_sha256,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -93,8 +107,11 @@ function buildReport() {
|
|||||||
staleFiles.push({
|
staleFiles.push({
|
||||||
filename: legacyName,
|
filename: legacyName,
|
||||||
status: "STALE_DIVERGED_FROM_CANONICAL",
|
status: "STALE_DIVERGED_FROM_CANONICAL",
|
||||||
|
requires_action: true,
|
||||||
backend_sha256: legacyFile.sha256,
|
backend_sha256: legacyFile.sha256,
|
||||||
canonical_sha256: canonicalFile.sha256,
|
canonical_sha256: canonicalFile.sha256,
|
||||||
|
backend_normalized_sha256: legacyFile.normalized_sha256,
|
||||||
|
canonical_normalized_sha256: canonicalFile.normalized_sha256,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,9 +120,12 @@ function buildReport() {
|
|||||||
.map((name) => ({
|
.map((name) => ({
|
||||||
filename: name,
|
filename: name,
|
||||||
status: "CANONICAL_ONLY",
|
status: "CANONICAL_ONLY",
|
||||||
|
requires_action: false,
|
||||||
canonical_sha256: canonicalMap.get(name).sha256,
|
canonical_sha256: canonicalMap.get(name).sha256,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const actionRequired = staleFiles.filter((file) => file.requires_action);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
canonical_dir: path.relative(repoRoot, canonicalDir),
|
canonical_dir: path.relative(repoRoot, canonicalDir),
|
||||||
@ -124,6 +144,7 @@ function buildReport() {
|
|||||||
stale_diverged_total: staleFiles.filter(
|
stale_diverged_total: staleFiles.filter(
|
||||||
(f) => f.status === "STALE_DIVERGED_FROM_CANONICAL"
|
(f) => f.status === "STALE_DIVERGED_FROM_CANONICAL"
|
||||||
).length,
|
).length,
|
||||||
|
action_required_total: actionRequired.length,
|
||||||
canonical_only_total: canonicalOnly.length,
|
canonical_only_total: canonicalOnly.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -136,11 +157,15 @@ function printReport(report) {
|
|||||||
console.log(`- Generated: ${report.generated_at}`);
|
console.log(`- Generated: ${report.generated_at}`);
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
console.log(`Stale SQL files in legacy dir: ${report.summary.stale_total}`);
|
console.log(`Legacy SQL files in reference dir: ${report.summary.stale_total}`);
|
||||||
for (const stale of report.stale_sql_files) {
|
for (const stale of report.stale_sql_files) {
|
||||||
console.log(` - ${stale.filename} :: ${stale.status}`);
|
const actionText = stale.requires_action ? "action required" : "reference only";
|
||||||
|
console.log(` - ${stale.filename} :: ${stale.status} (${actionText})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`Action-required legacy SQL files: ${report.summary.action_required_total}`);
|
||||||
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(`Canonical-only SQL files: ${report.summary.canonical_only_total}`);
|
console.log(`Canonical-only SQL files: ${report.summary.canonical_only_total}`);
|
||||||
for (const canonicalOnly of report.canonical_only_sql_files) {
|
for (const canonicalOnly of report.canonical_only_sql_files) {
|
||||||
@ -174,7 +199,7 @@ function main() {
|
|||||||
writeReport(report);
|
writeReport(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.failOnStale && report.summary.stale_total > 0) {
|
if (options.failOnStale && report.summary.action_required_total > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user