chore: harden reliability checks
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m36s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 8s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s

This commit is contained in:
Nico 2026-05-25 16:20:35 -07:00
parent e4774ecd6a
commit a2c08aff45
17 changed files with 433 additions and 324 deletions

View File

@ -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" \

View File

@ -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" \

View File

@ -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).

View File

@ -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`.

View File

@ -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
} }
} }

View File

@ -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:

View File

@ -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

View File

@ -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",

View File

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

View 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);
});

View File

@ -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 }) => {

View File

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

View File

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

View 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([]);
}

View File

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

View File

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

View File

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