From 7b2b8f6ca96e7e934cc5c508d13558ada09320d4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:23:43 +0000 Subject: [PATCH] fix: harden mdtodo web-probe selection --- .../hwlab-node-web-observe-runner-source.ts | 139 +++++++++++++++++- 1 file changed, 132 insertions(+), 7 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 0ff78c81..1844a452 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -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,