fix(time-tracking): Recent Activity reads from session brand; add cycle 12 smoke scripts

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.
This commit is contained in:
Nora
2026-07-04 01:02:00 -06:00
parent 9d0e325c26
commit 3db642e76b
3 changed files with 210 additions and 2 deletions
+103
View File
@@ -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);
});
+98
View File
@@ -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);
});
+9 -2
View File
@@ -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