Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions src/automation/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"}`);
}
Expand Down Expand Up @@ -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() ?? "";
Expand Down
10 changes: 6 additions & 4 deletions src/kernel/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion src/kernel/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});

Expand Down Expand Up @@ -91,14 +113,18 @@ 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;
for (const sel of submitSelectors) {
try {
const el = page.locator(sel);
if (await el.count() > 0) {
await el.first().click();
await el.first().click({ timeout: 5000 });
submitted = true;
break;
}
Expand Down
66 changes: 66 additions & 0 deletions tests/automation/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 13 additions & 1 deletion tests/kernel/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading