From a2c08aff45e40b2b65bcdb36d5e50acb6a2e3938 Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 25 May 2026 16:20:35 -0700 Subject: [PATCH] chore: harden reliability checks --- .gitea/workflows/main-deploy.yml | 40 +++--- .gitea/workflows/new-deploy.yml | 40 +++--- PROJECT_INSTRUCTIONS.md | 2 + backend/migrations/add_image_columns.sql | 24 +--- backend/migrations/stale-sql-report.json | 56 ++++++-- docs/DB_MIGRATION_WORKFLOW.md | 5 +- docs/DEVELOPMENT.md | 6 +- frontend/package.json | 6 +- frontend/playwright.config.ts | 6 - frontend/scripts/run-playwright.mjs | 80 ++++++++++++ .../tests/available-items-catalog.spec.ts | 77 +++-------- frontend/tests/buy-modal-auto-advance.spec.ts | 61 ++------- .../tests/grocery-list-assignment.spec.ts | 62 +++------ frontend/tests/helpers/e2e.ts | 120 ++++++++++++++++++ frontend/tests/household-onboarding.spec.ts | 28 +--- frontend/tests/invite-link-management.spec.ts | 111 ++++++---------- scripts/db-stale-sql-tracker.js | 33 ++++- 17 files changed, 433 insertions(+), 324 deletions(-) create mode 100644 frontend/scripts/run-playwright.mjs create mode 100644 frontend/tests/helpers/e2e.ts diff --git a/.gitea/workflows/main-deploy.yml b/.gitea/workflows/main-deploy.yml index a7faba3..9aa02f0 100644 --- a/.gitea/workflows/main-deploy.yml +++ b/.gitea/workflows/main-deploy.yml @@ -13,26 +13,34 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22.12.0 # ------------------------- - # 🔹 BACKEND TESTS + # Verification gate # ------------------------- - - name: Install backend dependencies - working-directory: backend - run: npm ci + - name: Install dependencies + run: | + npm ci + npm --prefix backend ci + npm --prefix frontend ci - - name: Run backend tests - working-directory: backend - run: npm test --if-present + - name: Run reliability verification + run: | + 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 run: | @@ -40,7 +48,7 @@ jobs: -u "${{ secrets.REGISTRY_USER }}" --password-stdin # ------------------------- - # 🔹 Build Backend Image + # Build Backend Image # ------------------------- - name: Build Backend Image run: | @@ -55,7 +63,7 @@ jobs: docker push $REGISTRY/backend:latest # ------------------------- - # 🔹 Build Frontend Image + # Build Frontend Image # ------------------------- - name: Build Frontend Image run: | @@ -75,7 +83,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install SSH key run: | @@ -117,9 +125,9 @@ jobs: echo "Deployment job finished with status: $STATUS" if [ "$STATUS" = "success" ]; then - MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}" + MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}" else - MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}" + MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}" fi curl -d "$MSG" \ diff --git a/.gitea/workflows/new-deploy.yml b/.gitea/workflows/new-deploy.yml index fa4da44..0c0287e 100644 --- a/.gitea/workflows/new-deploy.yml +++ b/.gitea/workflows/new-deploy.yml @@ -15,26 +15,34 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22.12.0 # ------------------------- - # 🔹 BACKEND TESTS + # Verification gate # ------------------------- - - name: Install backend dependencies - working-directory: backend - run: npm ci + - name: Install dependencies + run: | + npm ci + npm --prefix backend ci + npm --prefix frontend ci - - name: Run backend tests - working-directory: backend - run: npm test --if-present + - name: Run reliability verification + run: | + 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 run: | @@ -42,7 +50,7 @@ jobs: -u "${{ secrets.REGISTRY_USER }}" --password-stdin # ------------------------- - # 🔹 Build Backend Image + # Build Backend Image # ------------------------- - name: Build Backend Image run: | @@ -57,7 +65,7 @@ jobs: docker push $REGISTRY/backend:${{ env.IMAGE_TAG }} # ------------------------- - # 🔹 Build Frontend Image + # Build Frontend Image # ------------------------- - name: Build Frontend Image run: | @@ -97,7 +105,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install SSH key run: | @@ -139,9 +147,9 @@ jobs: echo "Deployment job finished with status: $STATUS" if [ "$STATUS" = "success" ]; then - MSG="🚀 Grocery App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}" + MSG="Grocery App Deployment succeeded: $REGISTRY:${{ github.sha }}" else - MSG="❌ Grocery App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}" + MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}" fi curl -d "$MSG" \ diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index c535c08..4b75b70 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -193,6 +193,7 @@ Usage rules: --- ## 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). - Each commit must: - follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) @@ -200,4 +201,5 @@ Usage rules: - exclude secrets, credentials, and generated noise - 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. +- 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). diff --git a/backend/migrations/add_image_columns.sql b/backend/migrations/add_image_columns.sql index 6f5a061..9777037 100644 --- a/backend/migrations/add_image_columns.sql +++ b/backend/migrations/add_image_columns.sql @@ -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 -ALTER TABLE grocery_list -ADD COLUMN item_image BYTEA, -ADD COLUMN image_mime_type VARCHAR(50); +ALTER TABLE grocery_list +ADD COLUMN IF NOT EXISTS item_image BYTEA, +ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50); --- Optional: Add index for faster queries when filtering by items with images -CREATE INDEX 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`. +-- Index to speed up queries that filter by rows with images. +CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image +ON grocery_list ((item_image IS NOT NULL)); diff --git a/backend/migrations/stale-sql-report.json b/backend/migrations/stale-sql-report.json index 4ca5f0e..4277b3e 100644 --- a/backend/migrations/stale-sql-report.json +++ b/backend/migrations/stale-sql-report.json @@ -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", "legacy_dir": "backend\\migrations", "stale_sql_files": [ { "filename": "add_display_name_column.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, "backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f", - "canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f" + "canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f", + "normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f" }, { "filename": "add_image_columns.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", - "backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a", - "canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a" + "requires_action": false, + "backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded", + "canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded", + "normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded" }, { "filename": "add_modified_on_column.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, "backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b", - "canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b" + "canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b", + "normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08" }, { "filename": "add_notes_column.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, "backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a", - "canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a" + "canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a", + "normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a" }, { "filename": "create_item_classification_table.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, "backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a", - "canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a" + "canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a", + "normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50" }, { "filename": "multi_household_architecture.sql", "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, "backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e", - "canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e" + "canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e", + "normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e" } ], "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", "status": "CANONICAL_ONLY", + "requires_action": false, "canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030" }, { "filename": "zz_group_invites_and_join_policies.sql", "status": "CANONICAL_ONLY", - "canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3" + "requires_action": false, + "canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b" } ], "legacy_non_sql_files": [ - "MIGRATION_GUIDE.md" + "MIGRATION_GUIDE.md", + "stale-sql-report.json" ], "summary": { "stale_total": 6, "stale_only_in_backend_total": 0, "stale_duplicate_total": 6, "stale_diverged_total": 0, - "canonical_only_total": 2 + "action_required_total": 0, + "canonical_only_total": 5 } } diff --git a/docs/DB_MIGRATION_WORKFLOW.md b/docs/DB_MIGRATION_WORKFLOW.md index eddb72e..a4c573b 100644 --- a/docs/DB_MIGRATION_WORKFLOW.md +++ b/docs/DB_MIGRATION_WORKFLOW.md @@ -20,13 +20,16 @@ This project uses an external on-prem Postgres database. Migration files are can - `npm run db:migrate:new -- ` - Track stale legacy SQL in `backend/migrations`: - `npm run db:migrate:stale` -- Fail when stale legacy SQL exists: +- Fail when legacy SQL needs operator attention: - `npm run db:migrate:stale:check` ## Active migration set Migration files are applied in lexicographic filename order from `packages/db/migrations`. `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. Current baseline files: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5853ee6..2a573d6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -35,7 +35,7 @@ Important variables: | `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. | | `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. | | `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 Docker dev stack: @@ -70,6 +70,10 @@ npm run build 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: ```bash diff --git a/frontend/package.json b/frontend/package.json index 758095a..6e454a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,9 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed", - "test:e2e:ui": "playwright test --ui" + "test:e2e": "node scripts/run-playwright.mjs", + "test:e2e:headed": "node scripts/run-playwright.mjs --headed", + "test:e2e:ui": "node scripts/run-playwright.mjs --ui" }, "dependencies": { "axios": "^1.13.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 69d44cc..0cdb0ec 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -20,10 +20,4 @@ export default defineConfig({ use: { browserName: "chromium", channel: "chrome" }, }, ], - webServer: { - command: "npm run dev -- --host localhost --port 3010", - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, }); diff --git a/frontend/scripts/run-playwright.mjs b/frontend/scripts/run-playwright.mjs new file mode 100644 index 0000000..baf9f0b --- /dev/null +++ b/frontend/scripts/run-playwright.mjs @@ -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); +}); diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts index c7bc0ff..071d12c 100644 --- a/frontend/tests/available-items-catalog.spec.ts +++ b/frontend/tests/available-items-catalog.spec.ts @@ -1,54 +1,17 @@ import { expect, test } from "@playwright/test"; - -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", "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 }, - ]), - }); - }); -} +import { + confirmSlide, + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; 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 mockHouseholdAndStoreShell(page); + await mockHouseholdAndStoreShell(page, { + household: { name: "Catalog House" }, + }); 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.getByText("Delete milk?")).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(); + await confirmSlide(page); - 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(); - - await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item"); - await expect(managerModal.getByText("milk")).toHaveCount(0); + await expect( + 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); }); test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => { diff --git a/frontend/tests/buy-modal-auto-advance.spec.ts b/frontend/tests/buy-modal-auto-advance.spec.ts index 0d2ca14..7bfb7bc 100644 --- a/frontend/tests/buy-modal-auto-advance.spec.ts +++ b/frontend/tests/buy-modal-auto-advance.spec.ts @@ -1,4 +1,9 @@ import { expect, test } from "@playwright/test"; +import { + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; type MockItem = { id: number; @@ -15,29 +20,6 @@ type MockItem = { 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( id: number, itemName: string, @@ -68,24 +50,8 @@ async function setupBuyModalRoutes( let activeItems = initialItems.map((item) => ({ ...item })); let recentItems: MockItem[] = []; - await page.route("**/households", async (route) => { - await route.fulfill({ - 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 mockHouseholdAndStoreShell(page, { + household: { name: "Auto Advance House" }, }); 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(); 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 }) => { - await seedAuthStorage(page); + await seedAuthStorage(page, { username: "buy-modal-user" }); await mockConfig(page); await setupBuyModalRoutes(page, [ 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 }) => { - await seedAuthStorage(page); + await seedAuthStorage(page, { username: "buy-modal-user" }); await mockConfig(page); await setupBuyModalRoutes(page, [ 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 }) => { - await seedAuthStorage(page); + await seedAuthStorage(page, { username: "buy-modal-user" }); await mockConfig(page); await setupBuyModalRoutes(page, [ 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 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 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 }) => { - await seedAuthStorage(page); + await seedAuthStorage(page, { username: "buy-modal-user" }); await mockConfig(page); await setupBuyModalRoutes(page, [ makeItem(1, "solo", 1), diff --git a/frontend/tests/grocery-list-assignment.spec.ts b/frontend/tests/grocery-list-assignment.spec.ts index 51c7557..263021f 100644 --- a/frontend/tests/grocery-list-assignment.spec.ts +++ b/frontend/tests/grocery-list-assignment.spec.ts @@ -1,30 +1,12 @@ import { expect, test } from "@playwright/test"; - -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", "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, - }), - }); - }); -} +import { + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; 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); const members = [ @@ -62,24 +44,8 @@ test("assigned items render selected users and keep the picker menu outside the }> = []; let addCallCount = 0; - await page.route("**/households", async (route) => { - await route.fulfill({ - 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 mockHouseholdAndStoreShell(page, { + household: { name: "Assignment House" }, }); 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"); 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"); 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); await portalMenu.getByRole("option", { name: "Casey Client" }).click(); + await expect(portalMenu).toHaveCount(0); await assignModal.getByRole("button", { name: "Confirm" }).click(); + await expect(assignModal).toHaveCount(0); await expect(page.getByText("Adding for: Casey Client")).toBeVisible(); 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.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 expect(portalMenu).toHaveCount(0); await assignModal.getByRole("button", { name: "Confirm" }).click(); + await expect(assignModal).toHaveCount(0); await expect(page.getByText("Adding for: Jordan Client")).toBeVisible(); 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("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"); }); diff --git a/frontend/tests/helpers/e2e.ts b/frontend/tests/helpers/e2e.ts new file mode 100644 index 0000000..1f4cbc2 --- /dev/null +++ b/frontend/tests/helpers/e2e.ts @@ -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([]); +} diff --git a/frontend/tests/household-onboarding.spec.ts b/frontend/tests/household-onboarding.spec.ts index 8ebeaab..0f644ee 100644 --- a/frontend/tests/household-onboarding.spec.ts +++ b/frontend/tests/household-onboarding.spec.ts @@ -1,30 +1,8 @@ import { expect, test } from "@playwright/test"; - -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, - }), - }); - }); -} +import { mockConfig, seedAuthStorage } from "./helpers/e2e"; 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 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 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: "Create Household", exact: true }).click(); diff --git a/frontend/tests/invite-link-management.spec.ts b/frontend/tests/invite-link-management.spec.ts index cc93b8d..70f0678 100644 --- a/frontend/tests/invite-link-management.spec.ts +++ b/frontend/tests/invite-link-management.spec.ts @@ -1,49 +1,14 @@ import { expect, test } from "@playwright/test"; - -function seedAuthStorage(page: import("@playwright/test").Page, role = "admin") { - return page.addInitScript((seedRole) => { - localStorage.setItem("token", "test-token"); - localStorage.setItem("userId", "1"); - localStorage.setItem("role", seedRole); - localStorage.setItem("username", "manager-user"); - }, 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(); -} +import { + collectFailedApiRequests, + confirmSlide, + expectNoFailedApiRequests, + mockConfig, + seedAuthStorage, +} from "./helpers/e2e"; 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 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({ status: 200, contentType: "application/json", body: JSON.stringify({ link: { - token: "approval-token", + token: "approvaltoken", status: "ACTIVE", viewerStatus: null, 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.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 expect(page).toHaveURL(/\/invite\/approval-token$/); + await expect(page).toHaveURL(/\/invite\/approvaltoken$/); await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible(); }); 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); 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 }) => { - await seedAuthStorage(page, "owner"); + const failedApiRequests = collectFailedApiRequests(page); + await seedAuthStorage(page, { role: "owner", username: "manager-user" }); await mockConfig(page); let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]; @@ -231,32 +199,36 @@ test("household owner can transfer ownership from household settings", async ({ }); await page.route("**/households/1/members", async (route) => { - const request = route.request(); - if (request.method() === "PATCH") { - const body = request.postDataJSON() as { role?: string }; - if (body.role === "owner") { - households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }]; - members = [ - { id: 1, username: "manager-user", role: "admin" }, - { id: 2, username: "nico-admin", role: "owner" }, - ]; - } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(members), + }); + }); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - message: "Household ownership transferred successfully", - member: { user_id: 2, role: body.role || "member" }, - }), - }); + await page.route("**/households/1/members/*/role", async (route) => { + const request = route.request(); + if (request.method() !== "PATCH") { + await route.fulfill({ status: 405 }); return; } + const body = request.postDataJSON() as { role?: string }; + if (body.role === "owner") { + households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }]; + members = [ + { id: 1, username: "manager-user", role: "admin" }, + { id: 2, username: "nico-admin", role: "owner" }, + ]; + } + await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(members), + body: JSON.stringify({ + message: "Household ownership transferred successfully", + member: { user_id: 2, role: body.role || "member" }, + }), }); }); @@ -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("🛠️ Admin")).toContainText("Admin"); await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0); + expectNoFailedApiRequests(failedApiRequests); }); diff --git a/scripts/db-stale-sql-tracker.js b/scripts/db-stale-sql-tracker.js index d9b0e19..8714ed6 100644 --- a/scripts/db-stale-sql-tracker.js +++ b/scripts/db-stale-sql-tracker.js @@ -30,6 +30,16 @@ function sha256File(filePath) { 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) { return fs .readdirSync(dirPath) @@ -48,6 +58,7 @@ function mapByNameWithHash(dirPath, names) { name, path: path.join(dirPath, name), sha256: sha256File(path.join(dirPath, name)), + normalized_sha256: sha256Text(comparableSql(path.join(dirPath, name))), }); } return map; @@ -75,17 +86,20 @@ function buildReport() { staleFiles.push({ filename: legacyName, status: "STALE_ONLY_IN_BACKEND", + requires_action: true, backend_sha256: legacyFile.sha256, }); continue; } - if (legacyFile.sha256 === canonicalFile.sha256) { + if (legacyFile.normalized_sha256 === canonicalFile.normalized_sha256) { staleFiles.push({ filename: legacyName, status: "STALE_DUPLICATE_OF_CANONICAL", + requires_action: false, backend_sha256: legacyFile.sha256, canonical_sha256: canonicalFile.sha256, + normalized_sha256: legacyFile.normalized_sha256, }); continue; } @@ -93,8 +107,11 @@ function buildReport() { staleFiles.push({ filename: legacyName, status: "STALE_DIVERGED_FROM_CANONICAL", + requires_action: true, backend_sha256: legacyFile.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) => ({ filename: name, status: "CANONICAL_ONLY", + requires_action: false, canonical_sha256: canonicalMap.get(name).sha256, })); + const actionRequired = staleFiles.filter((file) => file.requires_action); + return { generated_at: new Date().toISOString(), canonical_dir: path.relative(repoRoot, canonicalDir), @@ -124,6 +144,7 @@ function buildReport() { stale_diverged_total: staleFiles.filter( (f) => f.status === "STALE_DIVERGED_FROM_CANONICAL" ).length, + action_required_total: actionRequired.length, canonical_only_total: canonicalOnly.length, }, }; @@ -136,11 +157,15 @@ function printReport(report) { console.log(`- Generated: ${report.generated_at}`); 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) { - 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(`Canonical-only SQL files: ${report.summary.canonical_only_total}`); for (const canonicalOnly of report.canonical_only_sql_files) { @@ -174,7 +199,7 @@ function main() { writeReport(report); } - if (options.failOnStale && report.summary.stale_total > 0) { + if (options.failOnStale && report.summary.action_required_total > 0) { process.exit(1); } }