Compare commits
2 Commits
9d0e325c26
...
e98bbc220f
| Author | SHA1 | Date | |
|---|---|---|---|
| e98bbc220f | |||
| 3db642e76b |
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -94,8 +94,24 @@ export async function verifyTimeTrackingPin(
|
|||||||
// a small worker pool this is fine; if it grows we could index a
|
// a small worker pool this is fine; if it grows we could index a
|
||||||
// lookup table, but per-brand counts are typically <100.
|
// lookup table, but per-brand counts are typically <100.
|
||||||
// Cycle 10: workers now live in the unified `field_workers` table.
|
// Cycle 10: workers now live in the unified `field_workers` table.
|
||||||
// We filter to time-domain roles here so a water-only worker can't
|
// We accept anyone with a clocking-in role: 'worker' (default),
|
||||||
// accidentally be matched against a time-tracking PIN.
|
// 'time_admin' (manages tasks + workers), 'irrigator' (legacy role
|
||||||
|
// for water workers, can also clock in/out — these are field crew
|
||||||
|
// who do both jobs), and 'driver' (cycle 12 driver/crew role).
|
||||||
|
//
|
||||||
|
// Excluded on purpose:
|
||||||
|
// - 'water_admin' — admin who manages headgates/workers, not a
|
||||||
|
// clocking-in field worker
|
||||||
|
// - 'supervisor' — cycle 12 approver role; doesn't clock in/out,
|
||||||
|
// only reviews and approves timesheets
|
||||||
|
//
|
||||||
|
// This also matters because MobileWaterApp (the /water unified
|
||||||
|
// entrypoint) calls verifyTimeTrackingPin best-effort right after
|
||||||
|
// verifyWaterPin — if the role filter here is too narrow, the
|
||||||
|
// worker enters their PIN once, the Time tab mounts, asks for the
|
||||||
|
// PIN again, and the second attempt fails the same way. Allowing
|
||||||
|
// the broader set keeps the unified PIN flow working for everyone
|
||||||
|
// who's registered as a field crew member.
|
||||||
const rows = await withBrand(brandId, async (db) => {
|
const rows = await withBrand(brandId, async (db) => {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -104,7 +120,7 @@ export async function verifyTimeTrackingPin(
|
|||||||
and(
|
and(
|
||||||
eq(fieldWorkers.brandId, brandId),
|
eq(fieldWorkers.brandId, brandId),
|
||||||
eq(fieldWorkers.active, true),
|
eq(fieldWorkers.active, true),
|
||||||
sql`${fieldWorkers.role} IN ('worker', 'time_admin')`,
|
sql`${fieldWorkers.role} IN ('worker', 'time_admin', 'irrigator', 'driver')`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -783,7 +799,14 @@ export async function getRecentTimeLogs(
|
|||||||
Date.now() - safeDays * 24 * 60 * 60 * 1000,
|
Date.now() - safeDays * 24 * 60 * 60 * 1000,
|
||||||
).toISOString();
|
).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
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: timeTrackingLogs.id,
|
id: timeTrackingLogs.id,
|
||||||
@@ -798,7 +821,7 @@ export async function getRecentTimeLogs(
|
|||||||
.from(timeTrackingLogs)
|
.from(timeTrackingLogs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(timeTrackingLogs.brandId, brandId),
|
eq(timeTrackingLogs.brandId, resolvedBrand),
|
||||||
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
|
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
|
||||||
gte(timeTrackingLogs.clockIn, new Date(since)),
|
gte(timeTrackingLogs.clockIn, new Date(since)),
|
||||||
// Only show logs that have a clock-out (the Recent Activity
|
// Only show logs that have a clock-out (the Recent Activity
|
||||||
|
|||||||
Reference in New Issue
Block a user