fix: harden mdtodo web-probe selection
This commit is contained in:
@@ -3411,14 +3411,86 @@ async function visibleLocator(locator) {
|
|||||||
return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false);
|
return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedProjectText(value) {
|
||||||
|
return String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSnapshotMatchesCommandSelection(type, raw, selected, targetValue) {
|
||||||
|
if (!raw || typeof raw !== "object") return false;
|
||||||
|
const selectedValue = String(selected?.selectedValue || "");
|
||||||
|
const normalizedTarget = normalizedProjectText(targetValue);
|
||||||
|
if (type === "selectMdtodoFile") {
|
||||||
|
const selectedFileRef = String(raw.selectedFileRefRaw || "");
|
||||||
|
const selectedFileLabel = normalizedProjectText(raw.selectedFileLabel);
|
||||||
|
return Boolean(
|
||||||
|
(selectedValue && selectedFileRef === selectedValue)
|
||||||
|
|| (normalizedTarget && selectedFileLabel.includes(normalizedTarget))
|
||||||
|
) && Number(raw.fileCount || 0) > 0;
|
||||||
|
}
|
||||||
|
if (type === "selectMdtodoSource" || type === "selectProjectSource") {
|
||||||
|
const selectedSourceId = String(raw.selectedSourceIdRaw || "");
|
||||||
|
return Boolean(selectedValue && selectedSourceId === selectedValue) && Number(raw.sourceCount || 0) > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForProjectCommandSelection({ type, selected, targetValue, timeoutMs = 30000 }) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastProject = null;
|
||||||
|
do {
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
lastProject = await projectManagementCommandSnapshot({ includeRaw: true });
|
||||||
|
if (projectSnapshotMatchesCommandSelection(type, lastProject, selected, targetValue)) return lastProject;
|
||||||
|
} while (Date.now() - started < timeoutMs);
|
||||||
|
return lastProject || await projectManagementCommandSnapshot({ includeRaw: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function selectHtmlOptionByValueOrLabel(locator, value) {
|
async function selectHtmlOptionByValueOrLabel(locator, value) {
|
||||||
const select = locator.first();
|
const select = locator.first();
|
||||||
const targetValue = typeof value === "string" && value.trim() ? value.trim() : "";
|
const targetValue = typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
if (targetValue) {
|
if (targetValue) {
|
||||||
const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
||||||
if (byValue.ok && byValue.selected.length > 0) return { mode: "select-value", selectedValue: byValue.selected[0] || targetValue };
|
if (byValue.ok && byValue.selected.length > 0) return { ok: true, mode: "select-value", selectedValue: byValue.selected[0] || targetValue };
|
||||||
const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
||||||
if (byLabel.ok && byLabel.selected.length > 0) return { mode: "select-label", selectedValue: byLabel.selected[0] || targetValue };
|
if (byLabel.ok && byLabel.selected.length > 0) return { ok: true, mode: "select-label", selectedValue: byLabel.selected[0] || targetValue };
|
||||||
|
return await select.evaluate((element, target) => {
|
||||||
|
const normalize = (raw) => String(raw || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
||||||
|
const normalizedTarget = normalize(target);
|
||||||
|
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: String(option.textContent || "").replace(/\s+/gu, " ").trim(),
|
||||||
|
}));
|
||||||
|
const matchers = [
|
||||||
|
{ mode: "select-value-normalized", test: (option) => normalize(option.value) === normalizedTarget },
|
||||||
|
{ mode: "select-label-normalized", test: (option) => normalize(option.label) === normalizedTarget },
|
||||||
|
{ mode: "select-label-contains", test: (option) => normalize(option.label).includes(normalizedTarget) },
|
||||||
|
{ mode: "select-value-contains", test: (option) => normalize(option.value).includes(normalizedTarget) },
|
||||||
|
];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
const chosen = options.find(matcher.test);
|
||||||
|
if (!chosen) continue;
|
||||||
|
element.value = chosen.value;
|
||||||
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: matcher.mode,
|
||||||
|
selectedValue: chosen.value,
|
||||||
|
selectedLabel: chosen.label,
|
||||||
|
optionCount: options.length,
|
||||||
|
optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8),
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mode: "select-target-not-found",
|
||||||
|
selectedValue: element.value || "",
|
||||||
|
optionCount: options.length,
|
||||||
|
optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8),
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
}, targetValue);
|
||||||
}
|
}
|
||||||
const selectedValue = await select.evaluate((element) => {
|
const selectedValue = await select.evaluate((element) => {
|
||||||
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value);
|
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value);
|
||||||
@@ -3429,7 +3501,7 @@ async function selectHtmlOptionByValueOrLabel(locator, value) {
|
|||||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
return chosen.value;
|
return chosen.value;
|
||||||
});
|
});
|
||||||
return { mode: "select-first", selectedValue };
|
return { ok: true, mode: "select-first", selectedValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) {
|
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) {
|
||||||
@@ -3441,8 +3513,28 @@ async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, sel
|
|||||||
const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]');
|
const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]');
|
||||||
if (await visibleLocator(select)) {
|
if (await visibleLocator(select)) {
|
||||||
const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || "");
|
const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || "");
|
||||||
await page.waitForTimeout(700);
|
const afterProjectRaw = await waitForProjectCommandSelection({ type, selected, targetValue: targetValue || "" });
|
||||||
const afterProject = await projectManagementCommandSnapshot();
|
const selectionMatched = projectSnapshotMatchesCommandSelection(type, afterProjectRaw, selected, targetValue || "");
|
||||||
|
const afterProject = sanitizeProjectCommandSnapshot(afterProjectRaw);
|
||||||
|
if (targetValue && (selected.ok !== true || !selectionMatched)) {
|
||||||
|
const error = new Error(type + " did not select requested target");
|
||||||
|
error.details = {
|
||||||
|
beforeUrl,
|
||||||
|
afterUrl: currentPageUrl(),
|
||||||
|
type,
|
||||||
|
attr,
|
||||||
|
selectTestId,
|
||||||
|
mode: selected.mode,
|
||||||
|
targetHash: sha256Text(targetValue),
|
||||||
|
targetPreview: truncate(targetValue, 120),
|
||||||
|
selected,
|
||||||
|
beforeProject,
|
||||||
|
afterProject,
|
||||||
|
pageId,
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
beforeUrl,
|
beforeUrl,
|
||||||
afterUrl: currentPageUrl(),
|
afterUrl: currentPageUrl(),
|
||||||
@@ -3507,7 +3599,7 @@ async function selectMdtodoFile(command) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mdtodoTaskLocator(command) {
|
async function mdtodoTaskLocator(command, options = {}) {
|
||||||
const taskRef = commandValue(command, ["taskRef"]);
|
const taskRef = commandValue(command, ["taskRef"]);
|
||||||
const taskId = commandValue(command, ["taskId", "task", "value", "text"]);
|
const taskId = commandValue(command, ["taskId", "task", "value", "text"]);
|
||||||
const selectors = [];
|
const selectors = [];
|
||||||
@@ -3524,15 +3616,48 @@ async function mdtodoTaskLocator(command) {
|
|||||||
const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first();
|
const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first();
|
||||||
if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId };
|
if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId };
|
||||||
}
|
}
|
||||||
|
if ((taskRef || taskId) && options.allowFallback !== true) {
|
||||||
|
return { locator: null, taskRef, taskId, selector: "target-task-not-visible", targetMissing: true };
|
||||||
|
}
|
||||||
const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first();
|
const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first();
|
||||||
return { locator: fallback, taskRef, taskId, selector: "first-visible-task" };
|
return { locator: fallback, taskRef, taskId, selector: "first-visible-task" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForMdtodoTaskLocator(command, timeoutMs = 30000) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastProject = null;
|
||||||
|
let lastTarget = null;
|
||||||
|
do {
|
||||||
|
lastTarget = await mdtodoTaskLocator(command, { allowFallback: false });
|
||||||
|
if (lastTarget.locator && await visibleLocator(lastTarget.locator)) return { target: lastTarget, project: lastProject };
|
||||||
|
lastProject = await projectManagementCommandSnapshot();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} while (Date.now() - started < timeoutMs);
|
||||||
|
return { target: lastTarget || await mdtodoTaskLocator(command, { allowFallback: false }), project: lastProject || await projectManagementCommandSnapshot() };
|
||||||
|
}
|
||||||
|
|
||||||
async function selectMdtodoTask(command) {
|
async function selectMdtodoTask(command) {
|
||||||
ensureProjectManagementCommand("selectMdtodoTask");
|
ensureProjectManagementCommand("selectMdtodoTask");
|
||||||
const beforeUrl = currentPageUrl();
|
const beforeUrl = currentPageUrl();
|
||||||
const beforeProject = await projectManagementCommandSnapshot();
|
const beforeProject = await projectManagementCommandSnapshot();
|
||||||
const target = await mdtodoTaskLocator(command);
|
const hasExplicitTarget = Boolean(commandValue(command, ["taskRef"]) || commandValue(command, ["taskId", "task", "value", "text"]));
|
||||||
|
const waited = hasExplicitTarget ? await waitForMdtodoTaskLocator(command) : { target: await mdtodoTaskLocator(command, { allowFallback: true }), project: null };
|
||||||
|
const target = waited.target;
|
||||||
|
if (!target.locator) {
|
||||||
|
const error = new Error("selectMdtodoTask did not find requested task");
|
||||||
|
error.details = {
|
||||||
|
beforeUrl,
|
||||||
|
afterUrl: currentPageUrl(),
|
||||||
|
selector: target.selector,
|
||||||
|
requestedTaskRef: target.taskRef ? opaqueIdSummary(target.taskRef) : null,
|
||||||
|
requestedTaskId: target.taskId || null,
|
||||||
|
beforeProject,
|
||||||
|
afterProject: waited.project,
|
||||||
|
pageId,
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
||||||
const clicked = await target.locator.evaluate((element) => ({
|
const clicked = await target.locator.evaluate((element) => ({
|
||||||
taskRef: element.getAttribute("data-task-ref") || null,
|
taskRef: element.getAttribute("data-task-ref") || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user