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
6 changes: 5 additions & 1 deletion src/automation/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,11 @@ async function autoLogin(

throw new Error(
`Auto-login failed: ${data.error ?? data.loginError ?? "Login verification failed"}.\n` +
"Your credentials may be incorrect. Run `crono login` to update them."
"Your credentials may be incorrect — run `crono login` to update them.\n" +
"If they are known good, Cronometer is most likely throttling logins: it\n" +
"then serves the logged-out marketing page with no error text, which\n" +
"surfaces as this message. Wait a few minutes before trying again;\n" +
"retrying in a loop extends the throttle."
);
}
}
Expand Down
23 changes: 21 additions & 2 deletions src/kernel/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ import type {
} from "../automation/types.js";
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
* 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.
*/
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.
*/
const BROWSER_SESSION_TIMEOUT_SEC = 300;

export type KernelClient = AutomationClient;
export type {
CustomFoodEntry,
Expand Down Expand Up @@ -44,7 +63,7 @@ export async function getKernelClient(): Promise<KernelClient> {
}

process.env["KERNEL_API_KEY"] = apiKey;
const kernel = new Kernel();
const kernel = new Kernel({ timeout: KERNEL_REQUEST_TIMEOUT_MS });

return createAutomationClient(createKernelRuntimeFactory(kernel));
}
Expand All @@ -54,7 +73,7 @@ function createKernelRuntimeFactory(kernel: Kernel): AutomationRuntimeFactory {
const browser = await kernel.browsers.create({
headless: hasAutoCredentials,
stealth: true,
timeout_seconds: hasAutoCredentials ? 120 : 300,
timeout_seconds: BROWSER_SESSION_TIMEOUT_SEC,
});

return {
Expand Down
66 changes: 37 additions & 29 deletions src/kernel/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ export function buildLoginCheckCode(): string {
await page.waitForTimeout(1000);
const url = page.url();
${buildLoginPresentedCheckCode()}
const diaryPresented = await page.evaluate(() => {
return !!document.querySelector('i.diary-date-previous, i.diary-date-next') ||
/Energy\\s+\\d+\\.?\\d*\\s*kcal/i.test(document.body.innerText);
}).catch(() => false);
${buildDiaryPresentedCheckCode()}
const isLoggedIn = url.includes('#diary') && !url.includes('/login') && !url.includes('/signin') && !loginPresented && diaryPresented;
return { success: true, loggedIn: isLoggedIn, url, loginPresented, diaryPresented };
`;
Expand All @@ -41,36 +38,24 @@ export function buildNavigateToLoginCode(): string {
* Generate Playwright code that automates Cronometer login.
* Fills email/password, submits, and verifies login succeeded.
* Credentials are embedded via JSON.stringify for safe escaping.
*
* Navigates directly to /login/. An earlier version loaded the marketing
* homepage and clicked its "Log In" link, but that click frequently does not
* navigate — and because the loop recorded a successful *click* it also
* suppressed the direct-navigation fallback, leaving the form unreachable and
* failing with "Could not find email input on https://cronometer.com/".
*/
export function buildAutoLoginCode(username: string, password: string): string {
const safeUser = JSON.stringify(username);
const safePass = JSON.stringify(password);

return `
// Navigate to cronometer.com and click through to the login page
await page.goto('https://cronometer.com', { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});

// Click the "Log In" link in the top navigation
const loginLinkSelectors = ['a[href="/login/"]', 'a[href="/login"]', 'a:has-text("Log In")', 'a:has-text("Login")'];
let clickedLogin = false;
for (const sel of loginLinkSelectors) {
try {
const el = page.locator(sel);
if (await el.count() > 0) {
await el.first().click();
clickedLogin = true;
break;
}
} catch {}
}
if (!clickedLogin) {
// Fallback: navigate directly
await page.goto('https://cronometer.com/login/', { waitUntil: 'domcontentloaded', timeout: 15000 });
}
// Navigate straight to the login page.
await page.goto('https://cronometer.com/login/', { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});

// Wait for login page to load
await page.waitForSelector('input[type="email"], input[name="username"], input[name="email"], #email, #username', { timeout: 10000 }).catch(() => {});
await page.waitForSelector('input[type="email"], input[name="username"], input[name="email"], #email, #username', { timeout: 15000 }).catch(() => {});

// Fill email — try multiple selectors
const emailSelectors = ['input[type="email"]', 'input[name="username"]', 'input[name="email"]', '#email', '#username'];
Expand Down Expand Up @@ -123,13 +108,23 @@ export function buildAutoLoginCode(username: string, password: string): string {
return { success: false, loggedIn: false, url: page.url(), error: 'Could not find submit button on ' + page.url() };
}

// Wait for navigation after login
// Wait for navigation after login. The GWT app then needs a moment to swap
// the login UI for the app shell — checking too early made a login that had
// actually succeeded report "Login verification failed".
await page.waitForURL(u => !u.href.includes('/login') && !u.href.includes('/signin'), { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(500);
await page.waitForTimeout(3000);
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});

// Confirm with a positive signal — that the diary actually renders — rather
// than relying only on the absence of login UI, which races the app's boot.
await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);

const url = page.url();
${buildLoginPresentedCheckCode()}
const loggedIn = !url.includes('/login') && !url.includes('/signin') && !loginPresented;
${buildDiaryPresentedCheckCode()}
const loggedIn = !url.includes('/login') && !url.includes('/signin') && (diaryPresented || !loginPresented);

// If still on login page, check for error messages (rate limit, wrong creds, etc.)
let loginError = null;
Expand Down Expand Up @@ -157,6 +152,19 @@ export function buildAutoLoginCode(username: string, password: string): string {
`;
}

/**
* Generate code that sets `diaryPresented` — a positive signal that the diary
* UI actually rendered, rather than the mere absence of login UI.
*/
function buildDiaryPresentedCheckCode(): string {
return `
const diaryPresented = await page.evaluate(() => {
return !!document.querySelector('i.diary-date-previous, i.diary-date-next') ||
/Energy\\s+\\d+\\.?\\d*\\s*kcal/i.test(document.body.innerText);
}).catch(() => false);
`;
}

function buildLoginPresentedCheckCode(): string {
return `
const loginPresented = await page.evaluate(() => {
Expand Down
40 changes: 40 additions & 0 deletions tests/kernel/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock the @onkernel/sdk module so we can inspect constructor options.
vi.mock("@onkernel/sdk", () => {
const ctor = vi.fn().mockImplementation(() => ({
browsers: {
create: vi.fn(),
playwright: { execute: vi.fn() },
deleteByID: vi.fn(),
},
}));
return { default: ctor, __ctor: ctor };
});

async function getCtor() {
const mod = await import("@onkernel/sdk");
return (mod as unknown as { __ctor: ReturnType<typeof vi.fn> }).__ctor;
}

describe("getKernelClient", () => {
beforeEach(async () => {
(await getCtor()).mockClear();
process.env["KERNEL_API_KEY"] = "test-key";
});

it("should give the SDK a client timeout longer than the slowest automation", async () => {
const { getKernelClient } = await import("../../src/kernel/client.js");
await getKernelClient();

const ctor = await getCtor();
expect(ctor).toHaveBeenCalledTimes(1);

const opts = ctor.mock.calls[0]?.[0] as { timeout?: number } | undefined;
// The SDK default is 60s, but addCustomFood dispatches with timeout_sec 120.
// A client that gives up first lets the browser commit the change while
// crono reports failure, so retries silently duplicate data.
expect(opts?.timeout).toBeDefined();
expect(opts?.timeout).toBeGreaterThan(120_000);
});
});
26 changes: 23 additions & 3 deletions tests/kernel/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,17 @@ describe("buildNavigateToLoginCode", () => {
});

describe("buildAutoLoginCode", () => {
it("should start at cronometer.com and click login link", () => {
it("should navigate directly to the login page", () => {
const code = buildAutoLoginCode("user@test.com", "password123");
expect(code).toContain("cronometer.com");
expect(code).toContain("loginLinkSelectors");
expect(code).toContain("cronometer.com/login/");
});

it("should not depend on clicking the marketing homepage login link", () => {
const code = buildAutoLoginCode("user@test.com", "password123");
// The homepage link often fails to navigate, and clicking it suppressed
// the direct-navigation fallback.
expect(code).not.toContain("loginLinkSelectors");
expect(code).not.toContain("clickedLogin");
});

it("should fill email field", () => {
Expand Down Expand Up @@ -82,6 +89,19 @@ describe("buildAutoLoginCode", () => {
expect(code).toContain("!loginPresented");
});

it("should confirm login with a positive diary signal", () => {
const code = buildAutoLoginCode("user@test.com", "password123");
expect(code).toContain("diaryPresented");
expect(code).toContain("diary-date-previous");
expect(code).toContain("diaryPresented || !loginPresented");
});

it("should let the app shell settle before verifying", () => {
const code = buildAutoLoginCode("user@test.com", "password123");
// A 500ms wait raced the GWT boot and failed successful logins.
expect(code).not.toContain("waitForTimeout(500)");
});

it("should return a result object", () => {
const code = buildAutoLoginCode("user@test.com", "password123");
expect(code).toContain("return {");
Expand Down
Loading