Merge pull request #1321 from pikasTech/fix/2302-mdtodo-command-selection

fix: harden MDTODO web-probe selection
This commit is contained in:
Lyon
2026-06-30 21:24:38 +08:00
committed by GitHub
@@ -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,