Merge pull request #1321 from pikasTech/fix/2302-mdtodo-command-selection
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const select = locator.first();
|
||||
const targetValue = typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
if (targetValue) {
|
||||
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: [] }));
|
||||
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 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 }));
|
||||
return chosen.value;
|
||||
});
|
||||
return { mode: "select-first", selectedValue };
|
||||
return { ok: true, mode: "select-first", selectedValue };
|
||||
}
|
||||
|
||||
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) + '"]');
|
||||
if (await visibleLocator(select)) {
|
||||
const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || "");
|
||||
await page.waitForTimeout(700);
|
||||
const afterProject = await projectManagementCommandSnapshot();
|
||||
const afterProjectRaw = await waitForProjectCommandSelection({ type, selected, targetValue: targetValue || "" });
|
||||
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 {
|
||||
beforeUrl,
|
||||
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 taskId = commandValue(command, ["taskId", "task", "value", "text"]);
|
||||
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();
|
||||
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();
|
||||
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) {
|
||||
ensureProjectManagementCommand("selectMdtodoTask");
|
||||
const beforeUrl = currentPageUrl();
|
||||
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 });
|
||||
const clicked = await target.locator.evaluate((element) => ({
|
||||
taskRef: element.getAttribute("data-task-ref") || null,
|
||||
|
||||
Reference in New Issue
Block a user