/**
* @module pipeline/actionDiscovery
* @description Discovers actionable UI elements on a page and produces
* executable Action descriptors for the state explorer.
*
* Builds on top of the existing element data captured by
* {@link module:pipeline/pageSnapshot.takeSnapshot} and the scoring logic
* in {@link module:pipeline/elementFilter.scoreElement}. Instead of just
* filtering elements for AI prompt context, this module determines *what
* actions can be performed* on each element.
*
* ### Action types
* | Type | Elements |
* |----------|---------------------------------------------------|
* | `click` | buttons, links, tabs, menu items, role="button" |
* | `fill` | text inputs, email, password, search, tel, number |
* | `select` | `<select>` dropdowns, role="combobox" |
* | `submit` | submit buttons, form submit actions |
* | `check` | checkboxes, radio buttons, switches |
*
* ### Exports
* - {@link discoverActions} — `(snapshot) → Action[]`
* - {@link generateTestData} — `(field) → string`
*/
// Keywords that signal destructive / irreversible actions — these are
// deprioritised so the explorer doesn't accidentally delete data.
// Reuses the same keyword awareness as elementFilter.js HIGH_VALUE_BUTTON_KEYWORDS
// but inverted: these are *dangerous* rather than *valuable*.
const DESTRUCTIVE_KEYWORDS = [
"delete", "remove", "destroy", "reset", "clear all",
"unsubscribe", "deactivate", "close account",
];
// S3-08: keywords that signal a signup / registration form requiring email
// verification. Used by stateExplorer to decide whether to invoke the
// DisposableEmail flow instead of plain form-filling.
const SIGNUP_INTENT_KEYWORDS = [
"sign up", "signup", "register", "registration", "create account",
"create your account", "join", "get started", "open an account",
];
// Keywords that indicate a login form — used as a negative signal to prevent
// Signal 3 (email+password heuristic) from misclassifying login forms as signup.
const LOGIN_INTENT_KEYWORDS = [
"sign in", "signin", "log in", "login", "forgot password",
"reset password", "remember me",
];
/**
* detectSignupIntent(snapshot, formActions) → boolean
*
* Returns true if the given form actions appear to belong to a
* signup/registration flow that will likely require email verification.
* Checks:
* 1. Submit/click button text on the form
* 2. Page title / heading text in the snapshot
* 3. Presence of both an email field AND a password field (strong signal)
*
* @param {object} snapshot - Page snapshot from takeSnapshot
* @param {object[]} formActions - Action descriptors for a single form group
* @returns {boolean}
*/
export function detectSignupIntent(snapshot, formActions) {
// Signal 1: submit button text
const submitActions = formActions.filter(a => a.type === "submit" || a.type === "click");
const submitText = submitActions.map(a => (a.element?.text || "")).join(" ").toLowerCase();
if (SIGNUP_INTENT_KEYWORDS.some(k => submitText.includes(k))) return true;
// Signal 2: page title or h1/h2 heading
const pageText = `${snapshot?.title || ""} ${snapshot?.url || ""}`.toLowerCase();
if (SIGNUP_INTENT_KEYWORDS.some(k => pageText.includes(k))) return true;
// Signal 3: form contains BOTH an email field and a password field, BUT
// does NOT look like a login form. Plain email+password is ambiguous — login
// forms are the most common form with both fields. We require either:
// (a) no login keywords present, AND
// (b) a third distinguishing field (name, confirm password, etc.)
const hasEmail = formActions.some(a => a.type === "fill" && (a.element?.type === "email" || (a.element?.placeholder || "").toLowerCase().includes("email")));
const hasPassword = formActions.some(a => a.type === "fill" && (a.element?.type === "password" || (a.element?.placeholder || "").toLowerCase().includes("password")));
if (hasEmail && hasPassword) {
// Negative check: if submit text or page text contains login keywords, skip
const allText = `${submitText} ${pageText}`;
const looksLikeLogin = LOGIN_INTENT_KEYWORDS.some(k => allText.includes(k));
if (looksLikeLogin) return false;
// Positive check: require a distinguishing signal beyond email+password.
// A login form typically has exactly 1 password field; signup forms often
// have 2 (password + confirm password) or additional fields (name, etc.).
const passwordFields = formActions.filter(a => a.type === "fill" && (a.element?.type === "password" || (a.element?.placeholder || "").toLowerCase().includes("password")));
if (passwordFields.length >= 2) return true;
// Also check for non-email, non-password fields (name, username, phone, etc.)
// Must exclude fields detected as email/password by BOTH type AND placeholder,
// since hasEmail/hasPassword above also match via placeholder hints.
const extraFields = formActions.filter(a => {
if (a.type !== "fill") return false;
const elType = (a.element?.type || "").toLowerCase();
const elPlaceholder = (a.element?.placeholder || "").toLowerCase();
if (elType === "email" || elPlaceholder.includes("email")) return false;
if (elType === "password" || elPlaceholder.includes("password")) return false;
return true;
});
if (extraFields.length > 0) return true;
}
return false;
}
// ── Test data generators ────────────────────────────────────────────────────
const TEST_DATA = {
email: "sentri-test@example.com",
password: "SentriTest123!",
text: "Sentri test input",
search: "test query",
tel: "+1234567890",
number: "42",
url: "https://example.com",
date: "2025-01-15",
};
/**
* Generate a realistic test value for a form field based on its type,
* name, label, and placeholder.
*
* @param {object} field — element descriptor from pageSnapshot
* @returns {string} a plausible test value
*/
export function generateTestData(field) {
const type = (field.type || "").toLowerCase();
const hints = `${field.name || ""} ${field.label || ""} ${field.placeholder || ""} ${field.ariaLabel || ""}`.toLowerCase();
// Type-based matching first (strongest signal)
if (type === "email" || hints.includes("email")) return TEST_DATA.email;
if (type === "password" || hints.includes("password")) return TEST_DATA.password;
if (type === "search" || hints.includes("search")) return TEST_DATA.search;
if (type === "tel" || hints.includes("phone")) return TEST_DATA.tel;
if (type === "number" || hints.includes("amount") || hints.includes("quantity")) return TEST_DATA.number;
if (type === "url") return TEST_DATA.url;
if (type === "date") return TEST_DATA.date;
// Hint-based matching
if (hints.includes("name") || hints.includes("first") || hints.includes("last")) return "Jane Doe";
if (hints.includes("address") || hints.includes("street")) return "123 Test Street";
if (hints.includes("city")) return "Test City";
if (hints.includes("zip") || hints.includes("postal")) return "12345";
if (hints.includes("company") || hints.includes("organization")) return "Sentri Corp";
if (hints.includes("message") || hints.includes("comment") || hints.includes("description")) {
return "This is a test message from Sentri explorer.";
}
return TEST_DATA.text;
}
// ── Action type resolution ──────────────────────────────────────────────────
/**
* Determine the action type for an element based on its tag, type, and role.
*
* @param {object} el — element descriptor from pageSnapshot
* @returns {string|null} action type or null if not actionable
*/
function resolveActionType(el) {
const tag = (el.tag || "").toLowerCase();
const type = (el.type || "").toLowerCase();
const role = (el.role || "").toLowerCase();
// Form inputs → fill
if (tag === "textarea") return "fill";
if (tag === "input") {
if (["text", "email", "password", "search", "tel", "number", "url", "date", ""].includes(type)) return "fill";
if (["checkbox", "radio"].includes(type)) return "check";
if (type === "submit") return "submit";
if (type === "button") return "click";
return null; // hidden, file, etc. — skip
}
// Select → select
if (tag === "select" || role === "combobox" || role === "listbox") return "select";
// Checkable roles
if (["checkbox", "radio", "switch"].includes(role)) return "check";
// Submit buttons inside forms
if (tag === "button") {
if (type === "submit") return "submit";
// Buttons with submit-like text
const text = (el.text || "").toLowerCase();
if (el.formId && /submit|send|save|create|sign|log\s?in|register/i.test(text)) return "submit";
return "click";
}
// Links
if (tag === "a" && el.href) return "click";
// ARIA interactive roles
if (["button", "link", "menuitem", "tab", "option"].includes(role)) return "click";
return null;
}
// ── Selector strategy builder ───────────────────────────────────────────────
// Produces multiple selector strategies per element, ordered by resilience.
// Mirrors the self-healing waterfall in selfHealing.js (safeClick/safeFill).
/**
* Build an ordered list of Playwright selector strings for an element.
* The explorer tries them in order; the first that resolves wins.
*
* @param {object} el — element descriptor
* @param {string} actionType
* @returns {string[]} selector strings
*/
function buildSelectors(el, actionType) {
const selectors = [];
const text = (el.text || "").trim();
const label = (el.label || "").trim();
const placeholder = (el.placeholder || "").trim();
const ariaLabel = (el.ariaLabel || "").trim();
const testId = (el.testId || "").trim();
const role = (el.role || "").toLowerCase();
const name = (el.name || "").trim();
const id = (el.id || "").trim();
// 1. data-testid (most stable)
if (testId) selectors.push(`[data-testid="${testId}"]`);
// 2. Role-based (aligns with self-healing waterfall)
if (role && text) selectors.push(`role=${role}[name="${text}"]`);
// 3. Label (for inputs)
if (label) selectors.push(`label=${label}`);
// 4. Placeholder
if (placeholder) selectors.push(`placeholder=${placeholder}`);
// 5. aria-label
if (ariaLabel) selectors.push(`[aria-label="${ariaLabel}"]`);
// 6. Text content (for buttons/links)
if (text && actionType === "click") selectors.push(`text="${text}"`);
// 7. ID-based
if (id) selectors.push(`#${id}`);
// 8. Name attribute
if (name) selectors.push(`[name="${name}"]`);
return selectors;
}
// ── Priority scoring ────────────────────────────────────────────────────────
// Reuses the same value signals as elementFilter.js HIGH_VALUE_BUTTON_KEYWORDS
// but adapted for action prioritisation.
/**
* Score an action for exploration priority. Higher = explore first.
*
* @param {object} el — element descriptor
* @param {string} actionType
* @returns {number} 0–100
*/
function scoreAction(el, actionType) {
const text = (el.text || "").toLowerCase();
let score = 50; // base
// Submit actions are highest value — they trigger state transitions
if (actionType === "submit") score += 30;
// Form fills are high value — they set up state for submissions
if (actionType === "fill") score += 20;
// Login/auth interactions
if (/login|sign\s?in|register|sign\s?up|password/.test(text)) score += 25;
// Checkout/purchase
if (/checkout|buy|purchase|add to cart|pay/.test(text)) score += 20;
// Search
if (/search|find|filter/.test(text)) score += 15;
// CRUD
if (/create|new|add|edit|save|update/.test(text)) score += 15;
// Navigation CTAs
if (/get started|try|start|learn more|view/.test(text)) score += 10;
// Penalise destructive actions
if (DESTRUCTIVE_KEYWORDS.some(k => text.includes(k))) score -= 40;
// Penalise disabled elements
if (el.disabled) score -= 50;
// Bonus for elements with test IDs (more reliable selectors)
if (el.testId) score += 5;
// Bonus for required fields (more likely to be in critical flows)
if (el.required) score += 10;
return Math.max(0, Math.min(100, score));
}
// ── Main export ─────────────────────────────────────────────────────────────
/**
* Discover all actionable elements on a page and produce Action descriptors.
*
* @param {object} snapshot — page snapshot from {@link module:pipeline/pageSnapshot.takeSnapshot}
* @returns {Array<{
* type: string,
* selectors: string[],
* element: object,
* value: string|null,
* priority: number,
* isDestructive: boolean,
* formId: string
* }>} sorted by priority (highest first)
*/
export function discoverActions(snapshot) {
const actions = [];
const seen = new Set(); // deduplicate by tag:type:text
for (const el of (snapshot.elements || [])) {
// Skip invisible and disabled elements
if (el.visible === false) continue;
const actionType = resolveActionType(el);
if (!actionType) continue;
// Pre-filter cross-origin links — avoids the expensive click → wait →
// reject → restore cycle in the state explorer. The href is already
// available from pageSnapshot, so we can check origin without clicking.
if (actionType === "click" && (el.tag || "").toLowerCase() === "a" && el.href) {
try {
const linkHost = new URL(el.href, snapshot.url).hostname.replace(/^www\./i, "").toLowerCase();
const pageHost = new URL(snapshot.url).hostname.replace(/^www\./i, "").toLowerCase();
if (linkHost !== pageHost) continue; // skip — will always be rejected by origin guard
} catch { /* keep action if URL parsing fails */ }
}
// Deduplicate — same pattern as elementFilter.js
const key = `${el.tag}:${el.type}:${(el.text || "").toLowerCase().trim()}`;
if (seen.has(key)) continue;
seen.add(key);
const text = (el.text || "").toLowerCase();
const isDestructive = DESTRUCTIVE_KEYWORDS.some(k => text.includes(k));
const selectors = buildSelectors(el, actionType);
if (selectors.length === 0) continue; // no way to target this element
const priority = scoreAction(el, actionType);
actions.push({
type: actionType,
selectors,
element: {
tag: el.tag,
text: (el.text || "").slice(0, 80),
type: el.type || "",
role: el.role || "",
name: el.name || "",
id: el.id || "",
label: el.label || "",
placeholder: el.placeholder || "",
ariaLabel: el.ariaLabel || "",
testId: el.testId || "",
formId: el.formId || "",
},
value: actionType === "fill" ? generateTestData(el) : null,
priority,
isDestructive,
formId: el.formId || "",
});
}
// Sort by priority descending (highest-value actions explored first)
return actions.sort((a, b) => b.priority - a.priority);
}