/**
* @module pipeline/autoLogin
* @description Selector-less login helper. Given a Playwright page, a username
* and password, locates the three login form elements (username field,
* password field, submit button) via a semantic-first waterfall of locator
* strategies so users don't have to hand-author CSS selectors when creating
* a project.
*
* ### Strategies (in order, per field)
*
* **Username field**
* 1. `page.locator('input[type="email"]').first()`
* 2. `page.getByLabel(/email|user|login/i)`
* 3. `page.getByPlaceholder(/email|user|login/i)`
* 4. `page.getByRole('textbox', { name: /email|user|login/i })`
* 5. `page.locator('input[name*="email" i], input[name*="user" i], input[id*="email" i], input[id*="user" i]')`
* 6. First visible non-password `<input>` on the page (last resort).
*
* **Password field**
* 1. `page.locator('input[type="password"]').first()` — almost always wins.
*
* **Submit button**
* 1. `page.getByRole('button', { name: /sign in|log in|login|submit|continue/i })`
* 2. `page.locator('button[type="submit"], input[type="submit"]').first()`
* 3. `form button:not([type="button"])` scoped to the password field's form.
* 4. Fallback: press `Enter` inside the password field (browsers submit
* the form natively).
*
* Honest limitations: this is a best-effort heuristic, not an AI solver.
* It handles ~90% of conventional login pages (email + password + button)
* but will miss exotic flows (multi-step SSO, captchas, phone-number-first
* forms, shadow-DOM components without semantic roles). Those sites can
* still fall back to the recorder or legacy explicit selectors.
*
* ### Backwards compatibility
* Projects that already persist explicit `usernameSelector` / `passwordSelector`
* / `submitSelector` values continue to use them (fast path). This module is
* only invoked when those fields are blank.
*
* @example
* const ok = await performAutoLogin(page, {
* username: "alice@example.com",
* password: "secret",
* }, { timeout: 5000, logger: (m) => console.log(m) });
*/
/**
* Try each candidate locator until one resolves to a visible element or we
* run out. Returns the first winning Locator or null.
*
* Types intentionally kept loose (`object` / `Function`) so vanilla jsdoc
* can parse them — the `import('@playwright/test').Page` / `.Locator` syntax
* is valid TypeScript but unsupported by the jsdoc CLI we use in CI.
*
* @param {object} page - Playwright `Page` instance.
* @param {Array<Function>} strategies - Locator-building functions.
* @param {number} timeout - per-strategy visibility timeout (ms).
* @returns {Promise<object|null>} Playwright `Locator` or null.
* @private
*/
async function firstVisible(page, strategies, timeout) {
for (const build of strategies) {
try {
const locator = build();
await locator.first().waitFor({ state: "visible", timeout });
return locator.first();
} catch { /* next strategy */ }
}
return null;
}
/**
* Resolve the three login form elements by running the waterfall strategies.
*
* @param {object} page - Playwright `Page` instance.
* @param {number} timeout
* @returns {Promise<object>} Shape: `{ username, password, submit }`. Each
* value is a Playwright `Locator` or null. `submit` may be null if no
* button is found — the caller should fall back to pressing Enter on the
* password field.
* @private
*/
async function resolveLoginFields(page, timeout) {
const username = await firstVisible(page, [
() => page.locator('input[type="email"]'),
() => page.getByLabel(/e-?mail|user(name)?|login/i),
() => page.getByPlaceholder(/e-?mail|user(name)?|login/i),
() => page.getByRole("textbox", { name: /e-?mail|user(name)?|login/i }),
() => page.locator(
'input[name*="email" i], input[name*="user" i], input[id*="email" i], input[id*="user" i]'
),
// Last resort: first visible non-password text input.
() => page.locator('input:not([type="password"]):not([type="hidden"]):not([type="submit"]):not([type="button"])'),
], timeout);
const password = await firstVisible(page, [
() => page.locator('input[type="password"]'),
], timeout);
const submit = await firstVisible(page, [
() => page.getByRole("button", { name: /sign\s*in|log\s*in|login|submit|continue|next/i }),
() => page.locator('button[type="submit"], input[type="submit"]'),
// Any button inside a form that contains a password field.
() => page.locator('form:has(input[type="password"]) button:not([type="button"])'),
], timeout);
return { username, password, submit };
}
/**
* Attempt to log in by auto-detecting the login form elements.
*
* @param {object} page - Playwright `Page` already navigated to the login URL.
* @param {object} creds - `{ username, password }` strings.
* @param {object} [opts]
* @param {number} [opts.timeout=5000] - Per-strategy visibility timeout (ms).
* @param {Function} [opts.logger] - Optional logger `(msg) => void`.
* @returns {Promise<object>} Result envelope `{ ok: boolean, reason?: string }`.
* Never throws — transient Playwright errors are captured in `reason`.
*/
export async function performAutoLogin(page, { username, password }, { timeout = 5000, logger } = {}) {
const log = typeof logger === "function" ? logger : () => {};
if (!username || !password) {
return { ok: false, reason: "username and password are required" };
}
try {
const { username: userEl, password: passEl, submit: submitEl } = await resolveLoginFields(page, timeout);
if (!userEl) return { ok: false, reason: "Could not locate username/email field" };
if (!passEl) return { ok: false, reason: "Could not locate password field" };
await userEl.fill(username);
await passEl.fill(password);
if (submitEl) {
await submitEl.click({ timeout });
} else {
// No submit button found — pressing Enter submits the form natively
// in virtually all browsers when the focus is inside a password field
// that lives inside a <form>.
log("No submit button found, pressing Enter to submit");
await passEl.press("Enter");
}
return { ok: true };
} catch (err) {
return { ok: false, reason: err?.message || String(err) };
}
}