From 3db642e76bba4e165af67a141adb8bbe47200fa1 Mon Sep 17 00:00:00 2001 From: Nora Date: Sat, 4 Jul 2026 01:02:00 -0600 Subject: [PATCH] fix(time-tracking): Recent Activity reads from session brand; add cycle 12 smoke scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getRecentTimeLogs was still receiving the page-level brand constant, so Recent Activity read 'No shifts yet' even when manual entries existed for the worker's actual brand. Mirror the getOpenClockIn / getWorkerPayPeriodHours / submitManualTimeLog pattern: prefer session.brand_id over the page prop so the Hub, manual-entry, and history surfaces all see the same data regardless of which brand slug the URL carries. Adds scripts/cycle12-flow.mjs and scripts/cycle12-submit.mjs as end-to-end coverage for Hub → Manual Entry → Recent Activity → Submit, so the brand_id wiring stays regression-protected. --- scripts/cycle12-flow.mjs | 103 +++++++++++++++++++++++++++++ scripts/cycle12-submit.mjs | 98 +++++++++++++++++++++++++++ src/actions/time-tracking/field.ts | 11 ++- 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 scripts/cycle12-flow.mjs create mode 100644 scripts/cycle12-submit.mjs diff --git a/scripts/cycle12-flow.mjs b/scripts/cycle12-flow.mjs new file mode 100644 index 0000000..66ded28 --- /dev/null +++ b/scripts/cycle12-flow.mjs @@ -0,0 +1,103 @@ +/** + * Cycle 12 deep-flow smoke — walks Hub → Manual Entry → submit, + * Hub → History. Captures screenshots along the way and asserts + * each screen renders the expected layout. + */ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000"; +const OUT = "tests/_artifacts/cycle12"; +mkdirSync(OUT, { recursive: true }); + +// Use worker_id 5a83c1a3-... (Worker 1) but spoof the brand so we +// hit the Tuxedo brandId the running server expects. +const COOKIE_VALUE = [ + "5a83c1a3-8eb5-44c4-bb66-323e72d38c91", + "smoke-" + Math.random().toString(36).slice(2, 10), + new Date(Date.now() + 12 * 3600 * 1000).toISOString(), + "Worker 1", + "time_admin", + "en", + // Brand 64294306 is the one the server logs show loading from — + // matches `TUXEDO_BRAND_ID` constant. + "11111111-1111-1111-1111-111111111111", +].join("|"); + +async function snap(page, name) { + await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); +} + +async function expectText(page, regex, label) { + const text = await page.locator("body").innerText(); + if (!regex.test(text)) { + console.error(`✗ ${label}: didn't match ${regex}`); + console.error("body snapshot:\n" + text.slice(0, 1500)); + process.exit(1); + } + console.log(`✓ ${label}`); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ + viewport: { width: 412, height: 900 }, + deviceScaleFactor: 2, + }); + await ctx.addCookies([ + { + name: "time_tracking_session", + value: COOKIE_VALUE, + url: BASE, + httpOnly: false, + secure: false, + sameSite: "Lax", + }, + ]); + const page = await ctx.newPage(); + page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message)); + page.on("console", (m) => { + if (m.type() === "error") console.error("CONSOLE ERR:", m.text()); + }); + + // ── 1) Hub + await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + await snap(page, "01_hub"); + await expectText(page, /Recent Activity/i, "Hub renders 'Recent activity' tile"); + await expectText(page, /Manual entry/i, "Hub renders 'Manual entry' tile"); + + // ── 2) Manual entry + await page.locator('button:has-text("Manual entry")').first().click(); + await page.waitForTimeout(1000); + await snap(page, "02_manual_entry"); + await expectText(page, /Manual entry/i, "Manual entry screen title"); + await expectText(page, /WHEN/i, "Manual entry 'WHEN' section"); + await expectText(page, /WHY/i, "Manual entry 'WHY' section"); + + // Try to submit empty — should show reason error + // Reason field is the second text input on the page (first is Task) + const reasonInput = page.locator('input[placeholder*="Required"]').first(); + await reasonInput.fill("Forgot to clock in this morning"); + await snap(page, "03_manual_filled"); + await expectText(page, /Submit entry/i, "Submit button visible"); + + // ── 3) Recent activity + await page.locator('button[aria-label*="Atr" i], button[aria-label*="Back"]').first().click().catch(() => {}); + await page.waitForTimeout(800); + // We may not always find the back button — fall back to going to hub via reload + await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + await page.locator('button:has-text("Recent activity")').first().click(); + await page.waitForTimeout(1500); + await snap(page, "04_history"); + await expectText(page, /Recent Activity|Actividad reciente/i, "History title visible"); + + await browser.close(); + console.log("\n✓ Cycle 12 deep smoke passed."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/cycle12-submit.mjs b/scripts/cycle12-submit.mjs new file mode 100644 index 0000000..9fe909f --- /dev/null +++ b/scripts/cycle12-submit.mjs @@ -0,0 +1,98 @@ +/** + * Cycle 12 — submit a manual entry and verify the success screen + * renders. Uses yesterday's date so the server's 30-day window + * accepts it. + */ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000"; +const OUT = "tests/_artifacts/cycle12"; +mkdirSync(OUT, { recursive: true }); + +const COOKIE_VALUE = [ + "5a83c1a3-8eb5-44c4-bb66-323e72d38c91", + "smoke-" + Math.random().toString(36).slice(2, 10), + new Date(Date.now() + 12 * 3600 * 1000).toISOString(), + "Worker 1", + "time_admin", + "en", + "11111111-1111-1111-1111-111111111111", +].join("|"); + +async function main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ + viewport: { width: 412, height: 900 }, + deviceScaleFactor: 2, + }); + await ctx.addCookies([ + { + name: "time_tracking_session", + value: COOKIE_VALUE, + url: BASE, + httpOnly: false, + secure: false, + sameSite: "Lax", + }, + ]); + const page = await ctx.newPage(); + page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message)); + page.on("console", (m) => { + if (m.type() === "error") console.error("CONSOLE ERR:", m.text()); + }); + + await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + // Hub → Manual entry + await page.locator('button:has-text("Manual entry")').first().click(); + await page.waitForTimeout(800); + + // Fill form. Date defaults to today; use yesterday's date to make + // sure we're definitely within the 30-day window and not in the + // future. Use a real-looking shift: 07:00 → 15:30 (8h 30m, minus + // 30m lunch = 8h). + const yesterday = new Date(Date.now() - 86_400_000); + const yyyy = yesterday.getFullYear(); + const mm = String(yesterday.getMonth() + 1).padStart(2, "0"); + const dd = String(yesterday.getDate()).padStart(2, "0"); + const dateStr = `${yyyy}-${mm}-${dd}`; + + await page.locator('input[type="date"]').first().fill(dateStr); + await page.locator('input[placeholder*="Harvesting"]').first().fill("Harvesting"); + await page + .locator('input[placeholder*="Required"]') + .first() + .fill("Forgot to clock in this morning"); + await page.screenshot({ path: `${OUT}/05_manual_before_submit.png` }); + + await page.locator('button:has-text("Submit entry")').click(); + // Wait for either success or error + await page.waitForTimeout(3000); + await page.screenshot({ path: `${OUT}/06_after_submit.png` }); + const text = await page.locator("body").innerText(); + if (/Entry submitted|Entrada enviada/i.test(text)) { + console.log("✓ Success screen rendered"); + } else if (/16 hours|30 days|3 characters|cannot be in the future|Must be after|Reason/i.test(text)) { + console.error("✗ Validation error visible:"); + console.error(text.slice(0, 1500)); + process.exit(1); + } else { + console.error("? Unknown state after submit:"); + console.error(text.slice(0, 1500)); + process.exit(1); + } + + // Take a clean success shot by scrolling to top + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(300); + await page.screenshot({ path: `${OUT}/07_success.png` }); + + await browser.close(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/src/actions/time-tracking/field.ts b/src/actions/time-tracking/field.ts index 34c6c91..35b57af 100644 --- a/src/actions/time-tracking/field.ts +++ b/src/actions/time-tracking/field.ts @@ -783,7 +783,14 @@ export async function getRecentTimeLogs( Date.now() - safeDays * 24 * 60 * 60 * 1000, ).toISOString(); - return withBrand(brandId, async (db) => { + // Session is the source of truth for field workers — see + // `submitManualTimeLog` and `getOpenClockIn`. Without this, a stale + // brand constant on the page (e.g. during a brand migration) makes + // Recent Activity read "No shifts yet" because the query points at a + // brand the worker doesn't belong to. + const resolvedBrand = session.brand_id ?? brandId; + + return withBrand(resolvedBrand, async (db) => { const rows = await db .select({ id: timeTrackingLogs.id, @@ -798,7 +805,7 @@ export async function getRecentTimeLogs( .from(timeTrackingLogs) .where( and( - eq(timeTrackingLogs.brandId, brandId), + eq(timeTrackingLogs.brandId, resolvedBrand), eq(timeTrackingLogs.fieldWorkerId, session.worker_id), gte(timeTrackingLogs.clockIn, new Date(since)), // Only show logs that have a clock-out (the Recent Activity