Tighten browser inline overlay qualification
This commit is contained in:
+209
-24
@@ -36,21 +36,138 @@ function normalizeRole(rawRole) {
|
||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||
case "password":
|
||||
return "password";
|
||||
default:
|
||||
case "username":
|
||||
return "username";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function lowerJoined(values) {
|
||||
return values
|
||||
.map((value) => String(value || "").trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function fieldHintText(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
return "";
|
||||
}
|
||||
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
|
||||
return lowerJoined([
|
||||
input.getAttribute?.("type"),
|
||||
input.getAttribute?.("name"),
|
||||
input.getAttribute?.("id"),
|
||||
input.autocomplete,
|
||||
input.getAttribute?.("autocomplete"),
|
||||
input.getAttribute?.("placeholder"),
|
||||
input.getAttribute?.("aria-label"),
|
||||
...labels
|
||||
]);
|
||||
}
|
||||
|
||||
function textLikeInputType(type) {
|
||||
switch (String(type || "").toLowerCase()) {
|
||||
case "":
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
case "number":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hintMatches(text, patterns) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function scopeHintText(scope) {
|
||||
const isDocumentScope = typeof document !== "undefined" && scope === document;
|
||||
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
|
||||
return "";
|
||||
}
|
||||
const attrText = isDocumentScope ? "" : lowerJoined([
|
||||
scope.getAttribute?.("id"),
|
||||
scope.getAttribute?.("name"),
|
||||
scope.getAttribute?.("class"),
|
||||
scope.getAttribute?.("action"),
|
||||
scope.getAttribute?.("aria-label")
|
||||
]);
|
||||
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
|
||||
.slice(0, 8)
|
||||
.map((element) => element.textContent || ""));
|
||||
return lowerJoined([attrText, headingText]);
|
||||
}
|
||||
|
||||
function hasAuthFlowSignals(usernameInput, scope) {
|
||||
if (usernameInput) {
|
||||
return true;
|
||||
}
|
||||
return hintMatches(scopeHintText(scope), authScopePatterns);
|
||||
}
|
||||
|
||||
const usernameHintPatterns = [
|
||||
/\buser(name|id)?\b/,
|
||||
/\blog[\s_-]?in\b/,
|
||||
/\bsign[\s_-]?in\b/,
|
||||
/\bemail\b/,
|
||||
/\be-mail\b/,
|
||||
/\baccount\b/,
|
||||
/\bmember\b/,
|
||||
/\bidentifier\b/
|
||||
];
|
||||
|
||||
const nonLoginHintPatterns = [
|
||||
/\bsearch\b/,
|
||||
/\bquery\b/,
|
||||
/\bfilter\b/,
|
||||
/\bcomment\b/,
|
||||
/\bmessage\b/,
|
||||
/\bcontact\b/,
|
||||
/\bcity\b/,
|
||||
/\bstate\b/,
|
||||
/\bpostal\b/,
|
||||
/\bzip\b/,
|
||||
/\bcoupon\b/,
|
||||
/\bpromo\b/,
|
||||
/\bnewsletter\b/,
|
||||
/\bsubscribe\b/
|
||||
];
|
||||
|
||||
const authScopePatterns = [
|
||||
/\blog[\s_-]?in\b/,
|
||||
/\bsign[\s_-]?in\b/,
|
||||
/\bauth\b/,
|
||||
/\bpassword\b/,
|
||||
/\bpasscode\b/,
|
||||
/\b2fa\b/,
|
||||
/\btwo[\s-]?factor\b/,
|
||||
/\bverify\b/,
|
||||
/\baccount\b/
|
||||
];
|
||||
|
||||
function describeFieldRole(input) {
|
||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||
if (type === "password") {
|
||||
return "password";
|
||||
}
|
||||
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
||||
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
||||
if (!textLikeInputType(type)) {
|
||||
return "";
|
||||
}
|
||||
const hints = fieldHintText(input);
|
||||
if (!hints) {
|
||||
return "";
|
||||
}
|
||||
if (hintMatches(hints, nonLoginHintPatterns)) {
|
||||
return "";
|
||||
}
|
||||
if (hintMatches(hints, usernameHintPatterns)) {
|
||||
return "username";
|
||||
}
|
||||
return "username";
|
||||
return "";
|
||||
}
|
||||
|
||||
function isUsernameCandidate(input) {
|
||||
@@ -102,6 +219,40 @@ function firstVisibleUsername(scope) {
|
||||
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||
}
|
||||
|
||||
function authFlowCandidate(anchorInput) {
|
||||
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
|
||||
const scopeInputs = resolveFormInputs(anchorInput);
|
||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
|
||||
if (!passwordInput) {
|
||||
return null;
|
||||
}
|
||||
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
|
||||
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
usernameInput: associated.usernameInput,
|
||||
passwordInput,
|
||||
anchorInput: anchorInput || passwordInput,
|
||||
scope
|
||||
};
|
||||
}
|
||||
|
||||
function loginCandidates() {
|
||||
const candidates = [];
|
||||
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
|
||||
const candidate = authFlowCandidate(passwordInput);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function associatedFieldsForAnchor(anchorInput) {
|
||||
const scopeInputs = resolveFormInputs(anchorInput);
|
||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||
@@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) {
|
||||
}
|
||||
|
||||
function buildFieldDescriptor(input, role) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||
const form = input.form instanceof HTMLFormElement ? input.form : null;
|
||||
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
|
||||
const scope = form || document;
|
||||
const inputs = visibleInputs(scope);
|
||||
const fieldIndex = inputs.indexOf(input);
|
||||
@@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(descriptor.role);
|
||||
if (!normalizedRole) {
|
||||
return null;
|
||||
}
|
||||
const forms = Array.from(document.forms || []);
|
||||
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||
const scope = form || document;
|
||||
@@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) {
|
||||
function scanLoginFields() {
|
||||
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
|
||||
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
|
||||
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
|
||||
const allVisible = visibleInputs(document);
|
||||
const roles = allVisible
|
||||
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
|
||||
.map((input) => {
|
||||
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
|
||||
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
|
||||
});
|
||||
const explicitRole = describeFieldRole(activeUsable);
|
||||
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
|
||||
const candidates = loginCandidates();
|
||||
const chosen = activeTargets || candidates[0] || null;
|
||||
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
|
||||
const focusRole = explicitRole || describeFieldRole(anchorInput);
|
||||
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
|
||||
const roles = candidates.map((candidate) => {
|
||||
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
|
||||
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
|
||||
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
|
||||
});
|
||||
return {
|
||||
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
|
||||
usernameInput: targets.usernameInput,
|
||||
passwordInput: targets.passwordInput,
|
||||
pageHasLoginForm: Boolean(chosen),
|
||||
usernameInput: chosen?.usernameInput || null,
|
||||
passwordInput: chosen?.passwordInput || null,
|
||||
anchorInput,
|
||||
focusTarget,
|
||||
signature: roles.join("|")
|
||||
@@ -265,8 +421,8 @@ function inlineMatchSummary(match) {
|
||||
return parts.join(" · ") || "No username";
|
||||
}
|
||||
|
||||
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
|
||||
if (suppressed || !hasTarget) {
|
||||
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
|
||||
if (suppressed || idleHidden || !hasTarget) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
@@ -286,7 +442,11 @@ const contentTestExports = {
|
||||
chooseFillTargets,
|
||||
inlineMatchSummary,
|
||||
domainLabel,
|
||||
shouldShowInlineOverlay
|
||||
shouldShowInlineOverlay,
|
||||
fieldHintText,
|
||||
scopeHintText,
|
||||
hasAuthFlowSignals,
|
||||
authFlowCandidate
|
||||
};
|
||||
|
||||
if (isNodeTestEnv) {
|
||||
@@ -303,7 +463,9 @@ if (isNodeTestEnv) {
|
||||
};
|
||||
let chooserOpen = false;
|
||||
let inlineSuppressed = false;
|
||||
let inlineIdleHidden = false;
|
||||
let refreshTimer = null;
|
||||
let idleHideTimer = null;
|
||||
let lastReportedSignature = "";
|
||||
let lastReportedTarget = "";
|
||||
|
||||
@@ -464,6 +626,24 @@ if (isNodeTestEnv) {
|
||||
dock.dataset.open = "false";
|
||||
}
|
||||
|
||||
function clearIdleHideTimer() {
|
||||
if (idleHideTimer !== null) {
|
||||
clearTimeout(idleHideTimer);
|
||||
idleHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshInlineLifetime(shouldShow) {
|
||||
clearIdleHideTimer();
|
||||
if (!shouldShow || chooserOpen || pageState.pendingFill) {
|
||||
return;
|
||||
}
|
||||
idleHideTimer = window.setTimeout(() => {
|
||||
inlineIdleHidden = true;
|
||||
hideDock();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function positionDock() {
|
||||
const anchor = currentTarget();
|
||||
if (!anchor || dock.style.display === "none") {
|
||||
@@ -537,9 +717,10 @@ if (isNodeTestEnv) {
|
||||
|
||||
function renderInlineState() {
|
||||
const target = currentTarget();
|
||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
|
||||
|
||||
if (!shouldShow) {
|
||||
clearIdleHideTimer();
|
||||
hideDock();
|
||||
return;
|
||||
}
|
||||
@@ -558,17 +739,21 @@ if (isNodeTestEnv) {
|
||||
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||
renderMatches();
|
||||
positionDock();
|
||||
refreshInlineLifetime(shouldShow);
|
||||
}
|
||||
|
||||
function reportFieldState(force) {
|
||||
const scan = scanLoginFields();
|
||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
|
||||
inlineIdleHidden = false;
|
||||
}
|
||||
pageState = {
|
||||
...pageState,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => {
|
||||
assert.equal(content.domainLabel("not-a-url"), "");
|
||||
});
|
||||
|
||||
test("describeFieldRole only treats explicit account fields as usernames", () => {
|
||||
const loginField = {
|
||||
autocomplete: "username",
|
||||
labels: [],
|
||||
getAttribute(name) {
|
||||
const attrs = {
|
||||
type: "email",
|
||||
id: "crew-email",
|
||||
name: "email",
|
||||
placeholder: "Email address",
|
||||
"aria-label": "Email address"
|
||||
};
|
||||
return attrs[name] || "";
|
||||
}
|
||||
};
|
||||
const searchField = {
|
||||
autocomplete: "",
|
||||
labels: [],
|
||||
getAttribute(name) {
|
||||
const attrs = {
|
||||
type: "text",
|
||||
id: "site-search",
|
||||
name: "query",
|
||||
placeholder: "Search casino news",
|
||||
"aria-label": "Search"
|
||||
};
|
||||
return attrs[name] || "";
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(content.describeFieldRole(loginField), "username");
|
||||
assert.equal(content.describeFieldRole(searchField), "");
|
||||
});
|
||||
|
||||
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
|
||||
const genericScope = {
|
||||
getAttribute() {
|
||||
return "";
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [{ textContent: "Confirm shipment" }];
|
||||
}
|
||||
};
|
||||
const signInScope = {
|
||||
getAttribute(name) {
|
||||
const attrs = {
|
||||
id: "signin-panel",
|
||||
name: "signin",
|
||||
action: "/session"
|
||||
};
|
||||
return attrs[name] || "";
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [{ textContent: "Sign in to the Bellagio vault" }];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
|
||||
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
|
||||
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
|
||||
});
|
||||
|
||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||
const state = {
|
||||
pageHasLoginForm: true,
|
||||
@@ -29,5 +91,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
|
||||
};
|
||||
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
|
||||
});
|
||||
|
||||
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
||||
const state = {
|
||||
pageHasLoginForm: true,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: { locked: false },
|
||||
matches: [{ id: "rusty-ryan" }],
|
||||
pendingFill: false
|
||||
};
|
||||
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user