From 984a215227670f7c640f34786049f1b70a3e4da9 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:59:31 +0000 Subject: [PATCH 1/3] fix(quick-add): scale automation timeouts Co-authored-by: Dan --- src/automation/runner.ts | 48 +++++++++++++++++++++++- src/kernel/client.ts | 9 +++-- tests/automation/runner.test.ts | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 tests/automation/runner.test.ts diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 93e5455..2a3c2a0 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -26,6 +26,50 @@ import type { WeightData, } from "./types.js"; +const QUICK_ADD_SECONDS_PER_MACRO = 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 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 ( + Math.max(1, macroCount) * QUICK_ADD_SECONDS_PER_MACRO + + dateSteps * QUICK_ADD_SECONDS_PER_DATE_STEP + ); +} + export function createAutomationClient( createRuntime: AutomationRuntimeFactory ): AutomationClient { @@ -36,7 +80,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 +240,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..18e9314 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 420s (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,11 @@ 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 420s for four macros plus 90 date steps, so + * leave enough room for both login and the operation. */ -const BROWSER_SESSION_TIMEOUT_SEC = 300; +const BROWSER_SESSION_TIMEOUT_SEC = 600; export type KernelClient = AutomationClient; export type { diff --git a/tests/automation/runner.test.ts b/tests/automation/runner.test.ts new file mode 100644 index 0000000..3510785 --- /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 one minute for a single macro", () => { + expect(getQuickAddTimeoutSec({ protein: 45 }, now)).toBe(60); + }); + + it("scales the timeout for every macro dialog", () => { + expect( + getQuickAddTimeoutSec({ protein: 45, carbs: 90, fat: 55 }, now) + ).toBe(180); + }); + + it("includes the delay for previous-day navigation", () => { + expect(getQuickAddTimeoutSec({ fat: 55, date: "2026-08-30" }, now)).toBe( + 66 + ); + }); + + it("caps date navigation at the same 90 steps as the automation", () => { + expect(getQuickAddTimeoutSec({ protein: 1, date: "2025-01-01" }, now)).toBe( + 240 + ); + }); +}); + +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, 180]); + expect(close).toHaveBeenCalledOnce(); + }); +}); From 53ee40519f7ceda14b35c0479eae04fb7d9f10e0 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:06:33 +0000 Subject: [PATCH 2/3] fix(quick-add): allow time for diary setup Co-authored-by: Dan --- src/automation/runner.ts | 7 +++++-- src/kernel/client.ts | 7 ++++--- tests/automation/runner.test.ts | 12 ++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 2a3c2a0..9a2727f 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -27,6 +27,7 @@ import type { } 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; @@ -36,8 +37,9 @@ const AUTO_LOGIN_TIMEOUT_SEC = 120; * 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 per macro, plus the two-second delay used for each previous - * day navigation step in buildQuickAddCode. + * 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, @@ -65,6 +67,7 @@ export function getQuickAddTimeoutSec( } return ( + QUICK_ADD_SETUP_TIMEOUT_SEC + Math.max(1, macroCount) * QUICK_ADD_SECONDS_PER_MACRO + dateSteps * QUICK_ADD_SECONDS_PER_DATE_STEP ); diff --git a/src/kernel/client.ts b/src/kernel/client.ts index 18e9314..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 420s (see multi-macro retroactive quick-add). 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. @@ -32,10 +32,11 @@ const KERNEL_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; * Lifetime of a remote browser session. * * A single command can spend up to 120s logging in before a multi-macro quick-add - * starts. Quick-add may need up to 420s for four macros plus 90 date steps, so + * 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 = 600; +const BROWSER_SESSION_TIMEOUT_SEC = 900; export type KernelClient = AutomationClient; export type { diff --git a/tests/automation/runner.test.ts b/tests/automation/runner.test.ts index 3510785..a077674 100644 --- a/tests/automation/runner.test.ts +++ b/tests/automation/runner.test.ts @@ -15,25 +15,25 @@ import type { AutomationRuntime } from "../../src/automation/types.js"; describe("getQuickAddTimeoutSec", () => { const now = new Date("2026-09-02T12:00:00"); - it("allows one minute for a single macro", () => { - expect(getQuickAddTimeoutSec({ protein: 45 }, now)).toBe(60); + 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(180); + ).toBe(240); }); it("includes the delay for previous-day navigation", () => { expect(getQuickAddTimeoutSec({ fat: 55, date: "2026-08-30" }, now)).toBe( - 66 + 126 ); }); it("caps date navigation at the same 90 steps as the automation", () => { expect(getQuickAddTimeoutSec({ protein: 1, date: "2025-01-01" }, now)).toBe( - 240 + 300 ); }); }); @@ -60,7 +60,7 @@ describe("quick-add automation timeouts", () => { await client.addQuickEntry({ protein: 45, carbs: 90, fat: 55 }); - expect(execute.mock.calls.map((call) => call[1])).toEqual([30, 120, 180]); + expect(execute.mock.calls.map((call) => call[1])).toEqual([30, 120, 240]); expect(close).toHaveBeenCalledOnce(); }); }); From 33f831a79dc36bd7f53923c93816b1efd4acf662 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:19:56 +0000 Subject: [PATCH 3/3] fix(login): dismiss cookie consent before submit Co-authored-by: Dan --- src/kernel/login.ts | 28 +++++++++++++++++++++++++++- tests/kernel/login.test.ts | 14 +++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) 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/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", () => {