diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 93e5455..9a2727f 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -26,6 +26,53 @@ import type { WeightData, } from "./types.js"; +const QUICK_ADD_SECONDS_PER_MACRO = 60; +const QUICK_ADD_SETUP_TIMEOUT_SEC = 60; +const QUICK_ADD_SECONDS_PER_DATE_STEP = 2; +const MAX_QUICK_ADD_DATE_STEPS = 90; +const AUTO_LOGIN_TIMEOUT_SEC = 120; + +/** + * Quick-add logs every macro through a separate food-search dialog. A fixed + * 60-second timeout can therefore save the first macro and abort while adding + * the next one, leaving a partial (and unsafe to retry) diary entry. + * + * Allow one minute to load and prepare the diary, one minute per macro, plus + * the two-second delay used for each previous day navigation step in + * buildQuickAddCode. + */ +export function getQuickAddTimeoutSec( + entry: MacroEntry, + now = new Date() +): number { + const macroCount = [ + entry.protein, + entry.carbs, + entry.fat, + entry.alcohol, + ].filter((value) => value !== undefined).length; + + let dateSteps = 0; + if (entry.date) { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + const target = new Date(`${entry.date}T00:00:00`); + dateSteps = Math.min( + MAX_QUICK_ADD_DATE_STEPS, + Math.max( + 0, + Math.round((today.getTime() - target.getTime()) / (24 * 60 * 60 * 1000)) + ) + ); + } + + return ( + QUICK_ADD_SETUP_TIMEOUT_SEC + + Math.max(1, macroCount) * QUICK_ADD_SECONDS_PER_MACRO + + dateSteps * QUICK_ADD_SECONDS_PER_DATE_STEP + ); +} + export function createAutomationClient( createRuntime: AutomationRuntimeFactory ): AutomationClient { @@ -36,7 +83,7 @@ export function createAutomationClient( const data = await executeAutomation<{ success: boolean; error?: string; - }>(runtime, buildQuickAddCode(entry), 60); + }>(runtime, buildQuickAddCode(entry), getQuickAddTimeoutSec(entry)); if (!data.success) { throw new Error(`Quick add failed: ${data.error ?? "Unknown error"}`); } @@ -196,7 +243,7 @@ async function autoLogin( url: string; error?: string; loginError?: string | null; - }>(runtime, buildAutoLoginCode(username, password), 60); + }>(runtime, buildAutoLoginCode(username, password), AUTO_LOGIN_TIMEOUT_SEC); if (!data.loggedIn) { const pageError = data.loginError?.toLowerCase() ?? ""; diff --git a/src/kernel/client.ts b/src/kernel/client.ts index ab50890..5741517 100644 --- a/src/kernel/client.ts +++ b/src/kernel/client.ts @@ -21,7 +21,7 @@ import { getCredential } from "../credentials.js"; * Client-side HTTP timeout for Kernel API requests. * * The SDK defaults to 60s, but automations are dispatched with `timeout_sec` - * values up to 120s (see `addCustomFood`). When the client gives up first the + * values up to 480s (see multi-macro retroactive quick-add). When the client gives up first the * browser keeps going, so Cronometer commits the change while crono reports * failure — and a retry then duplicates the data. Keep this comfortably above * the largest `timeout_sec` used by any automation. @@ -31,10 +31,12 @@ const KERNEL_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; /** * Lifetime of a remote browser session. * - * A single command can spend ~60s logging in before a 120s automation even - * starts, so the previous 120s budget could expire mid-operation. + * A single command can spend up to 120s logging in before a multi-macro quick-add + * starts. Quick-add may need up to 480s for setup, four macros, and 90 date + * steps, so + * leave enough room for both login and the operation. */ -const BROWSER_SESSION_TIMEOUT_SEC = 300; +const BROWSER_SESSION_TIMEOUT_SEC = 900; export type KernelClient = AutomationClient; export type { diff --git a/src/kernel/login.ts b/src/kernel/login.ts index 9993664..8e5df67 100644 --- a/src/kernel/login.ts +++ b/src/kernel/login.ts @@ -54,6 +54,28 @@ export function buildAutoLoginCode(username: string, password: string): string { await page.goto('https://cronometer.com/login/', { waitUntil: 'domcontentloaded', timeout: 20000 }); await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + // Cookiebot can cover the login button even though the form is otherwise + // ready. Playwright then waits on every submit selector until the outer + // automation timeout expires. Dismiss only buttons inside Cookiebot. + async function dismissCookieConsent() { + const consentSelectors = [ + '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll', + '#CybotCookiebotDialogBodyButtonAccept', + '#CybotCookiebotDialog button:has-text("OK")', + ]; + for (const sel of consentSelectors) { + const button = page.locator(sel).filter({ visible: true }); + if (await button.count() > 0) { + await button.first().click({ timeout: 5000 }).catch(() => {}); + await page.waitForSelector('#CybotCookiebotDialog', { + state: 'hidden', timeout: 5000, + }).catch(() => {}); + break; + } + } + } + await dismissCookieConsent(); + // Wait for login page to load await page.waitForSelector('input[type="email"], input[name="username"], input[name="email"], #email, #username', { timeout: 15000 }).catch(() => {}); @@ -91,6 +113,10 @@ export function buildAutoLoginCode(username: string, password: string): string { return { success: false, loggedIn: false, url: page.url(), error: 'Could not find password input on ' + page.url() }; } + // The banner may finish loading after the inputs, so check again directly + // before submitting. + await dismissCookieConsent(); + // Click the LOG IN button const submitSelectors = ['#login-button', 'button:has-text("LOG IN")', 'button:has-text("Log In")', 'button[type="submit"]', 'input[type="submit"]']; let submitted = false; @@ -98,7 +124,7 @@ export function buildAutoLoginCode(username: string, password: string): string { try { const el = page.locator(sel); if (await el.count() > 0) { - await el.first().click(); + await el.first().click({ timeout: 5000 }); submitted = true; break; } diff --git a/tests/automation/runner.test.ts b/tests/automation/runner.test.ts new file mode 100644 index 0000000..a077674 --- /dev/null +++ b/tests/automation/runner.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/credentials.js", () => ({ + getCredential: vi.fn((key: string) => + key === "cronometer-username" ? "user@example.com" : "password" + ), +})); + +import { + createAutomationClient, + getQuickAddTimeoutSec, +} from "../../src/automation/runner.js"; +import type { AutomationRuntime } from "../../src/automation/types.js"; + +describe("getQuickAddTimeoutSec", () => { + const now = new Date("2026-09-02T12:00:00"); + + it("allows setup time plus one minute for a single macro", () => { + expect(getQuickAddTimeoutSec({ protein: 45 }, now)).toBe(120); + }); + + it("scales the timeout for every macro dialog", () => { + expect( + getQuickAddTimeoutSec({ protein: 45, carbs: 90, fat: 55 }, now) + ).toBe(240); + }); + + it("includes the delay for previous-day navigation", () => { + expect(getQuickAddTimeoutSec({ fat: 55, date: "2026-08-30" }, now)).toBe( + 126 + ); + }); + + it("caps date navigation at the same 90 steps as the automation", () => { + expect(getQuickAddTimeoutSec({ protein: 1, date: "2025-01-01" }, now)).toBe( + 300 + ); + }); +}); + +describe("quick-add automation timeouts", () => { + it("allows login and a multi-macro write to complete independently", async () => { + const execute = vi + .fn() + .mockResolvedValueOnce({ + success: true, + result: { success: true, loggedIn: false, url: "/login" }, + }) + .mockResolvedValueOnce({ + success: true, + result: { success: true, loggedIn: true, url: "/#diary" }, + }) + .mockResolvedValueOnce({ + success: true, + result: { success: true }, + }); + const close = vi.fn().mockResolvedValue(undefined); + const runtime = { execute, close } as unknown as AutomationRuntime; + const client = createAutomationClient(async () => runtime); + + await client.addQuickEntry({ protein: 45, carbs: 90, fat: 55 }); + + expect(execute.mock.calls.map((call) => call[1])).toEqual([30, 120, 240]); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/kernel/login.test.ts b/tests/kernel/login.test.ts index afce758..10853e4 100644 --- a/tests/kernel/login.test.ts +++ b/tests/kernel/login.test.ts @@ -74,7 +74,19 @@ describe("buildAutoLoginCode", () => { it("should click submit button", () => { const code = buildAutoLoginCode("user@test.com", "password123"); expect(code).toContain("submitSelectors"); - expect(code).toContain(".click()"); + expect(code).toContain("el.first().click({ timeout: 5000 })"); + }); + + it("should dismiss Cookiebot before submitting login", () => { + const code = buildAutoLoginCode("user@test.com", "password123"); + expect(code).toContain("dismissCookieConsent"); + expect(code).toContain("#CybotCookiebotDialog"); + expect(code).toContain('button:has-text("OK")'); + }); + + it("should bound each submit click attempt", () => { + const code = buildAutoLoginCode("user@test.com", "password123"); + expect(code).toContain("el.first().click({ timeout: 5000 })"); }); it("should verify login by checking URL", () => {