// SPEC: PJ2026-01040111 long-running Workbench observation. // Responsibility: Runner Workbench session, provider, and Project/MDTODO command source fragment. export function nodeWebObserveRunnerWorkbenchSource(): string { return String.raw`async function selectProvider(provider) { const target = String(provider || "").trim(); if (!target) throw new Error("selectProvider requires provider name"); const beforeUrl = currentPageUrl(); const beforePath = safeUrlPath(beforeUrl); if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first"); const nativeSelect = await page.evaluate((name) => { const normalized = String(name).toLowerCase(); const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { const options = Array.from(select.options || []); const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized)); if (!option) continue; select.value = option.value; select.dispatchEvent(new Event("input", { bubbles: true })); select.dispatchEvent(new Event("change", { bubbles: true })); return { kind: "native-select", value: option.value, text: option.textContent || "" }; } return null; }, target).catch(() => null); if (nativeSelect) { await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId }; } const optionVisible = page.getByText(target, { exact: false }).last(); if (await optionVisible.isVisible().catch(() => false)) { await optionVisible.click(); await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId }; } const control = page.locator([ '[data-testid*="provider" i]', '[data-testid*="model" i]', '[aria-label*="provider" i]', '[aria-label*="model" i]', '[aria-label*="模型"]', '[aria-label*="提供"]', '[role="combobox"]', '[aria-haspopup="listbox"]', ].join(", ")); const count = Math.min(await control.count().catch(() => 0), 60); const attempts = []; for (let index = count - 1; index >= 0; index -= 1) { const item = control.nth(index); const visible = await item.isVisible().catch(() => false); if (!visible) continue; const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => ""); if (isProviderNavigationLabel(label)) continue; if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue; attempts.push({ index, label }); await item.click({ timeout: 3000 }).catch(() => null); await page.waitForTimeout(300); const option = page.getByText(target, { exact: false }).last(); if (await option.isVisible().catch(() => false)) { await option.click(); await page.waitForTimeout(700); return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId }; } await page.keyboard.press("Escape").catch(() => null); } const providerCandidates = await collectProviderCandidates(); throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10))); } async function collectProviderCandidates() { return page.evaluate(() => { const rows = []; const seen = new Set(); const isNavigationLabel = (value) => { const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); if (!label) return false; if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true; return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, "")); }; const visible = (element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const push = (kind, element, value, text) => { const normalizedValue = String(value || "").trim().slice(0, 160); const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220); const label = (normalizedValue + " " + normalizedText).trim(); if (!label) return; const key = (kind + ":" + label).toLowerCase(); if (seen.has(key)) return; seen.add(key); rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) }); }; for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || ""); } const selector = [ '[role="option"]', '[role="menuitem"]', '[data-radix-collection-item]', '[data-testid*="provider" i]', '[data-testid*="profile" i]', '[data-testid*="model" i]', '[aria-label*="provider" i]', '[aria-label*="profile" i]', '[aria-label*="model" i]', '[aria-label*="模型"]', '[aria-label*="提供"]', 'button' ].join(", "); for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) { const text = String(element.textContent || "").replace(/\s+/gu, " ").trim(); const ariaLabel = String(element.getAttribute("aria-label") || ""); const testId = String(element.getAttribute("data-testid") || ""); const haystack = text + " " + ariaLabel + " " + testId; if (isNavigationLabel(haystack)) continue; if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue; push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId); } return rows.slice(0, 80); }).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]); } function isProviderNavigationLabel(value) { const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase(); if (!text) return false; if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true; return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, "")); } async function clickSession(sessionId) { if (!sessionId) throw new Error("clickSession requires --session-id or --value"); const beforeUrl = currentPageUrl(); const escaped = cssEscape(sessionId); const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first(); const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first(); await candidate.waitFor({ state: "visible", timeout: 15000 }); await candidate.click(); await page.waitForTimeout(1000); return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId }; } async function refreshCurrentSession(command) { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session"); const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); const after = await workbenchSessionSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type: "refreshCurrentSession", afterRound: integerOrNull(command.afterRound), canarySessionId: sessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, routeOk: after?.routeSessionId === sessionId, activeOk: after?.activeSessionId === sessionId, composerReady: after?.composerReady === true, navigation, pageId, valuesRedacted: true, }; } async function switchAwayAndBack(command) { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot(); const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim(); if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session"); const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create"); const beforeSessionIds = await visibleWorkbenchSessionIds(); let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null; let alternateSource = alternateSessionId === null ? null : "existing"; let createResult = null; if (alternateSessionId === null && strategy === "existing-or-create") { createResult = await createSessionFromUi(); alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null; alternateSource = "created"; } if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy); const switchAway = await clickSession(alternateSessionId); const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 }); const away = awaySettle.snapshot ?? await workbenchSessionSnapshot(); let switchBack; try { switchBack = await clickSession(canarySessionId); } catch (error) { const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId)); switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true }; } let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 }); let after = backSettle.snapshot ?? await workbenchSessionSnapshot(); let switchBackRecovery = null; let routeOk = after?.routeSessionId === canarySessionId; let activeOk = after?.activeSessionId === canarySessionId; if (!routeOk || !activeOk) { switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle); if (switchBackRecovery?.ok === true) { switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true }; backSettle = switchBackRecovery.settle; after = switchBackRecovery.snapshot ?? after; routeOk = after?.routeSessionId === canarySessionId; activeOk = after?.activeSessionId === canarySessionId; } } if (awaySettle.ok !== true) { const error = new Error("switchAwayAndBack did not settle on the alternate session"); error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true }; throw error; } if (!routeOk || !activeOk) { const error = new Error("switchAwayAndBack did not return to the canary session"); error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, recovery: switchBackRecovery, pageId, valuesRedacted: true }; throw error; } return { beforeUrl, afterUrl: currentPageUrl(), type: "switchAwayAndBack", afterRound: integerOrNull(command.afterRound), canarySessionId, alternateSessionId, alternateSessionStrategy: strategy, alternateSource, beforeSessionCount: beforeSessionIds.length, createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true }, switchAway, awaySettle, away: { routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, messageCount: away?.messageCount ?? null, valuesRedacted: true, }, switchBack, backSettle, switchBackRecovery, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, routeOk, activeOk, composerReady: after?.composerReady === true, pageId, valuesRedacted: true, }; } async function recoverControlPageSessionHydration(sessionId, previousSettle) { const observerHydration = observerPage && !observerPage.isClosed() ? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 }) : { ok: false, reason: "observer-page-unavailable", valuesRedacted: true }; await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1); const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 }); const snapshot = settle.snapshot ?? await workbenchSessionSnapshot(); const routeOk = snapshot?.routeSessionId === sessionId; const activeOk = snapshot?.activeSessionId === sessionId; return { ok: settle.ok === true && routeOk && activeOk, attempted: true, reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed", previousSettle, observerHydrated: observerHydration.ok === true, observerHydration, navigation, settle, snapshot: { routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, composerReady: snapshot?.composerReady === true, messageCount: snapshot?.messageCount ?? null, valuesRedacted: true, }, pageId, valuesRedacted: true, }; } async function assertSessionInvariant(command) { const beforeUrl = currentPageUrl(); const snapshot = await workbenchSessionSnapshot(); const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim(); if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session"); const routeOk = snapshot?.routeSessionId === canarySessionId; const activeOk = snapshot?.activeSessionId === canarySessionId; const composerReady = snapshot?.composerReady === true; if (!routeOk || !activeOk) { const error = new Error("assertSessionInvariant saw route/active session mismatch"); error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true }; throw error; } if (command.requireComposerReady === true && !composerReady) { const error = new Error("assertSessionInvariant requires composer ready for the next round"); error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true }; throw error; } const messageOrder = await visibleMessageOrderSummary(); await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true }))); return { beforeUrl, afterUrl: currentPageUrl(), type: "assertSessionInvariant", afterRound: integerOrNull(command.afterRound), canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, routeOk, activeOk, composerReady, expectedSentinelRange: command.expectedSentinelRange || null, findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation", severity: command.severity || "amber", blocking: command.blocking === true, messageOrder, sampleSeq, pageRole: "control", pageId, valuesRedacted: true, }; } async function visibleWorkbenchSessionIds() { return page.evaluate(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const sessionIdForElement = (element) => { const direct = element.getAttribute("data-session-id"); if (direct) return direct; const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || ""; const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u); return match ? decodeURIComponent(match[1] || "") : null; }; const ids = []; for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) { if (!visible(element)) continue; const id = sessionIdForElement(element); if (id && !ids.includes(id)) ids.push(id); } return ids.slice(0, 50); }).catch(() => []); } async function visibleMessageOrderSummary() { const rawMessages = await page.evaluate(() => { const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").trim().slice(0, limit); const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const stableMessageText = (element) => { const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"]; const parts = []; for (const selector of selectors) { for (const candidate of Array.from(element.querySelectorAll(selector))) { if (!visible(candidate)) continue; const text = trim(candidate.textContent || "", 1200); if (text && !parts.includes(text)) parts.push(text); } } return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200); }; return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({ index, dataRole: element.getAttribute("data-role") || null, role: element.getAttribute("role") || null, testId: element.getAttribute("data-testid") || null, status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, sessionId: element.getAttribute("data-session-id") || null, messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, traceId: element.getAttribute("data-trace-id") || null, turnId: element.getAttribute("data-turn-id") || null, text: stableMessageText(element), })); }).catch(() => []); const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => { const text = String(item?.text || ""); const kind = messageKindForOrder(item, text); const markers = sentinelMarkers(text); return { index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index, kind, role: item?.dataRole || item?.role || null, status: item?.status || null, sessionId: item?.sessionId || null, messageId: item?.messageId || null, traceId: item?.traceId || null, turnId: item?.turnId || null, markers, textHash: sha256Text(text), textBytes: Buffer.byteLength(text), valuesRedacted: true, }; }) : []; const clusters = userClusters(entries); const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null; return { messageCount: entries.length, sequence: entries.map((item) => ({ index: item.index, kind: item.kind, marker: item.markers[0] || null, markerCount: item.markers.length, traceId: item.traceId, status: item.status, textHash: item.textHash, textBytes: item.textBytes, valuesRedacted: true, })).slice(-40), userClustered: clusters.length > 0, consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0, sentinelRange: maxCluster?.sentinelRange ?? null, traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12), clusters, valuesRedacted: true, }; } function messageKindForOrder(item, text) { const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" "); if (/\b(user|human)\b/u.test(signal)) return "user"; if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent"; const body = String(text || "").trim(); if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user"; if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent"; return "unknown"; } function sentinelMarkers(text) { return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase())); } function userClusters(entries) { const clusters = []; let current = []; const flush = () => { if (current.length >= 2) { const markers = current.flatMap((item) => item.markers); clusters.push({ startIndex: current[0].index, endIndex: current[current.length - 1].index, consecutiveUserMessageCount: current.length, sentinelRange: sentinelRange(markers), markers: uniqueStrings(markers).slice(0, 12), messageTextHashes: current.map((item) => item.textHash).slice(0, 12), traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12), valuesRedacted: true, }); } current = []; }; for (const entry of entries) { if (entry.kind === "user") { current.push(entry); } else { flush(); } } flush(); return clusters.slice(0, 20); } function sentinelRange(markers) { const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b)); if (unique.length === 0) return null; return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1]; } function sentinelMarkerNumber(marker) { const match = String(marker || "").match(/sentinel-(\d+)/u); return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; } function uniqueStrings(values) { return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))); } function integerOrNull(value) { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : null; } function ensureProjectManagementCommand(type) { if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane"); if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane"); } async function gotoProjectMdtodo() { ensureProjectManagementCommand("gotoProjectMdtodo"); return gotoTarget("/projects/mdtodo"); } function commandValue(command, keys) { for (const key of keys) { const value = command?.[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return ""; } 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 { 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 { 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); const chosen = options[0] || null; if (!chosen) return ""; element.value = chosen.value; element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); return chosen.value; }); return { ok: true, mode: "select-first", selectedValue }; } async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const targetValue = typeof value === "string" && value.trim() ? value.trim() : null; if (selectTestId) { const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]'); if (await visibleLocator(select)) { const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || ""); 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(), type, attr, mode: selected.mode, selected: opaqueIdSummary(selected.selectedValue || targetValue), beforeProject, afterProject, pageId, valuesRedacted: true }; } } const selector = targetValue ? "[" + attr + "=\"" + cssEscape(targetValue) + "\"]" : fallbackSelector; const locator = page.locator(selector).first(); await locator.waitFor({ state: "visible", timeout: 15000 }); const clickedValue = await locator.evaluate((element, name) => element.getAttribute(name), attr).catch(() => targetValue); await locator.click(); await page.waitForTimeout(700); const afterProject = await projectManagementCommandSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type, attr, selected: opaqueIdSummary(clickedValue || targetValue), beforeProject, afterProject, pageId, valuesRedacted: true }; } async function selectProjectSource(command) { return clickProjectItemByAttr({ type: "selectProjectSource", attr: "data-source-id", value: command.sourceId || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', selectTestId: "mdtodo-source-select" }); } async function selectMdtodoSource(command) { return clickProjectItemByAttr({ type: "selectMdtodoSource", attr: "data-source-id", value: command.sourceId || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]', selectTestId: "mdtodo-source-select" }); } async function selectMdtodoFile(command) { return clickProjectItemByAttr({ type: "selectMdtodoFile", attr: "data-file-ref", value: command.fileRef || command.filename || command.value || command.text || "", fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]', selectTestId: "mdtodo-file-select" }); } async function mdtodoTaskLocator(command, options = {}) { const taskRef = commandValue(command, ["taskRef"]); const taskId = commandValue(command, ["taskId", "task", "value", "text"]); const selectors = []; if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]'); if (taskId) { selectors.push('[data-task-id="' + cssEscape(taskId) + '"]'); selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]'); } for (const selector of selectors) { const locator = page.locator(selector).first(); if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector }; } if (taskId) { 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 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, taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null, status: element.getAttribute("data-task-status") || null, selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true" })).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null })); if (!clicked.selected) { await target.locator.scrollIntoViewIfNeeded().catch(() => null); await target.locator.click(); await page.waitForTimeout(700); } const afterProject = await projectManagementCommandSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), type: "selectMdtodoTask", selector: target.selector, selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef), selectedTaskId: clicked.taskId || target.taskId || null, selectedTaskStatus: clicked.status || null, alreadySelected: clicked.selected === true, beforeProject, afterProject, pageId, valuesRedacted: true }; } async function expandMdtodoTask(command) { ensureProjectManagementCommand("expandMdtodoTask"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const target = await mdtodoTaskLocator(command); await target.locator.waitFor({ state: "visible", timeout: 15000 }); const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first(); const toggleVisible = await visibleLocator(toggle); if (toggleVisible) await toggle.click(); else await target.locator.click(); await page.waitForTimeout(700); return { beforeUrl, afterUrl: currentPageUrl(), type: "expandMdtodoTask", selector: target.selector, toggleVisible, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function openMdtodoSourceConfig(command) { ensureProjectManagementCommand("openMdtodoSourceConfig"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first(); if (await visibleLocator(existingDialog)) { return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoSourceConfig", alreadyOpen: true, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } const button = page.locator('[data-testid="mdtodo-source-config-open"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); await button.click(); await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null); return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoSourceConfig", beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function ensureMdtodoSourceConfigOpen() { const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first(); if (await visibleLocator(form)) return { opened: false }; await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" }); return { opened: true }; } async function closeMdtodoSourceConfig(command) { ensureProjectManagementCommand("closeMdtodoSourceConfig"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const close = await closeMdtodoSourceConfigIfOpen({ required: true }); return { beforeUrl, afterUrl: currentPageUrl(), type: "closeMdtodoSourceConfig", close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function closeMdtodoSourceConfigIfOpen(options = {}) { const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({ has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]') }).first(); if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false }; const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first(); let closeClick = null; try { await closeButton.waitFor({ state: "visible", timeout: 5000 }); await closeButton.click({ timeout: 5000 }); closeClick = { attempted: true, ok: true }; } catch (error) { closeClick = { attempted: true, ok: false, error: errorSummary(error) }; await page.keyboard.press("Escape").catch(() => null); } await page.waitForTimeout(400); const stillVisible = await visibleLocator(dialog); const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true }; if (options.required && stillVisible) { const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt"); error.details = result; throw error; } return result; } async function fillMdtodoField(testId, value) { if (typeof value !== "string" || !value.trim()) return { testId, filled: false }; const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); await locator.waitFor({ state: "visible", timeout: 10000 }); await locator.fill(value); return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; } async function clickProjectButtonAndMaybeWait(testId, pathPattern) { const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); const responsePromise = pathPattern ? page.waitForResponse((response) => { try { const pathname = new URL(response.url()).pathname; return pathPattern.test(pathname); } catch { return false; } }, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null); const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) })); if (buttonState.disabled === true) { const error = new Error(testId + " button is disabled"); error.details = { buttonState, valuesRedacted: true }; throw error; } await button.click(); const response = await responsePromise; await page.waitForTimeout(900); return { buttonState, response, valuesRedacted: true }; } async function configureMdtodoHwpodSource(command) { ensureProjectManagementCommand("configureMdtodoHwpodSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const dialog = await ensureMdtodoSourceConfigOpen(); const fields = [ await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])), await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])), await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])), await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])), ]; const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "configureMdtodoHwpodSource", dialog, fields, save, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function probeMdtodoSource(command) { ensureProjectManagementCommand("probeMdtodoSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); await ensureMdtodoSourceConfigOpen(); const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function reindexMdtodoSource(command) { ensureProjectManagementCommand("reindexMdtodoSource"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); await ensureMdtodoSourceConfigOpen(); const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first(); const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex"; const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u); const close = await closeMdtodoSourceConfigIfOpen(); return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function selectTaskIfCommandTargetsOne(command) { if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null; return selectMdtodoTask(command); } async function selectMdtodoProviderProfileForLaunch(command) { const provider = commandValue(command, ["provider", "providerProfile"]); if (!provider) return null; const select = page.locator('[data-testid="mdtodo-provider-profile"]').first(); if (!(await visibleLocator(select))) { return { provider, visible: false, selected: null, valuesRedacted: true }; } const selected = await selectHtmlOptionByValueOrLabel(select, provider); await page.waitForTimeout(300); return { provider, visible: true, ...selected, valuesRedacted: true }; } async function saveMdtodoTaskWithButton(command, type, testId, fields) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const inlineEditors = []; for (const field of fields) { if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId)); if (field.kind === "fill") await fillMdtodoField(field.testId, field.value); if (field.kind === "select") { const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first(); await locator.waitFor({ state: "visible", timeout: 10000 }); await selectHtmlOptionByValueOrLabel(locator, field.value); } } const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) { const candidates = (testIds || []).filter(Boolean); for (const testId of candidates) { const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern); } if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid"); return clickProjectButtonAndMaybeWait(candidates[0], pathPattern); } async function ensureMdtodoInlineEditor(editorTestId, readTestId) { const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first(); if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false }; const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first(); await read.waitFor({ state: "visible", timeout: 10000 }); await read.dblclick(); await editor.waitFor({ state: "visible", timeout: 10000 }); return { editorTestId, readTestId, opened: true }; } async function editMdtodoTaskTitle(command) { const title = commandValue(command, ["title", "text", "value"]); if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); } async function editMdtodoTaskBody(command) { const body = commandValue(command, ["text", "body", "value"]); if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); } async function toggleMdtodoTaskStatus(command) { const status = commandValue(command, ["status", "value", "text"]); if (!status) throw new Error("toggleMdtodoTaskStatus requires --status"); return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); } async function editMdtodoTaskInline(command) { const field = commandValue(command, ["field", "value"]).toLowerCase(); if (field === "title") { const title = commandValue(command, ["title", "text"]); if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]); } if (field === "body" || field === "content") { const body = commandValue(command, ["body", "text"]); if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); } if (field === "status") { const status = commandValue(command, ["status", "text"]); if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text"); return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]); } throw new Error("editMdtodoTaskInline requires --field title, body, or status"); } async function openMdtodoReportPreview(command) { ensureProjectManagementCommand("openMdtodoReportPreview"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const linkText = commandValue(command, ["link", "value", "text"]); const links = page.locator('[data-testid="mdtodo-report-link"]'); await links.first().waitFor({ state: "visible", timeout: 15000 }); const count = await links.count(); let index = 0; if (linkText) { for (let i = 0; i < count; i += 1) { const item = links.nth(i); const text = await item.textContent().catch(() => ""); if (String(text || "").includes(linkText)) { index = i; break; } } } const link = links.nth(index); const linkStateRaw = await link.evaluate((element, selectedIndex) => ({ index: selectedIndex, disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240), valuesRedacted: true }), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true })); const linkState = { ...linkStateRaw, textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null, textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null, text: undefined, valuesRedacted: true }; if (linkState.disabled === true) { const error = new Error("openMdtodoReportPreview selected report link is disabled"); error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true }; throw error; } const attempts = []; let response = null; let afterProject = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const responsePromise = page.waitForResponse((item) => { try { const request = item.request(); return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview"; } catch { return false; } }, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })); const clickMode = attempt === 1 ? "normal" : "force"; let click = { ok: true, mode: clickMode, valuesRedacted: true }; await link.scrollIntoViewIfNeeded().catch(() => null); try { if (attempt === 1) await link.click(); else await link.click({ force: true, timeout: 5000 }); } catch (error) { click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true }; await link.evaluate((element) => element.click()).catch(() => null); } await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null); afterProject = await projectManagementCommandSnapshot(); response = afterProject?.reportPreviewVisible === true ? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true } : await responsePromise; attempts.push({ attempt, click, response, afterUrl: currentPageUrl(), reportPreviewVisible: afterProject?.reportPreviewVisible === true, reportErrorVisible: afterProject?.reportErrorVisible === true, reportFullscreenVisible: afterProject?.reportFullscreenVisible === true, valuesRedacted: true }); if (afterProject?.reportPreviewVisible === true) break; await page.waitForTimeout(700).catch(() => null); } afterProject = afterProject ?? await projectManagementCommandSnapshot(); if (afterProject?.reportPreviewVisible !== true) { const error = new Error("openMdtodoReportPreview did not show markdown report preview"); error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; throw error; } return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true }; } async function toggleMdtodoReportFullscreen(command) { ensureProjectManagementCommand("toggleMdtodoReportFullscreen"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const desired = commandValue(command, ["value", "text"]).toLowerCase(); const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first(); const initiallyOpen = await visibleLocator(dialog); let action = "open"; if (desired === "close" || desired === "off") action = "close"; else if (desired === "toggle") action = initiallyOpen ? "close" : "open"; if (action === "close") { const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first(); await closeButton.waitFor({ state: "visible", timeout: 10000 }); await closeButton.click(); } else if (!initiallyOpen) { const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first(); await button.waitFor({ state: "visible", timeout: 10000 }); await button.click(); } await page.waitForTimeout(500); return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function fillNewTaskDraft(command) { const title = commandValue(command, ["title", "text", "value"]); if (!title) throw new Error(command.type + " requires --title or --text"); const fields = [await fillMdtodoField("mdtodo-new-title", title)]; const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : ""); if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body)); return fields; } async function addMdtodoTaskWithButton(command, type, testId) { ensureProjectManagementCommand(type); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command); const fields = await fillNewTaskDraft(command); const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function addMdtodoRootTask(command) { return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root"); } async function addMdtodoSubTask(command) { return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask"); } async function continueMdtodoTask(command) { return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task"); } async function deleteMdtodoTask(command) { ensureProjectManagementCommand("deleteMdtodoTask"); const beforeUrl = currentPageUrl(); const beforeProject = await projectManagementCommandSnapshot(); const selection = await selectTaskIfCommandTargetsOne(command); const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null); let confirmClick = null; const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()); if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } async function launchWorkbenchFromTask(command) { const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask"; ensureProjectManagementCommand(commandType); const beforeUrl = currentPageUrl(); const initialProject = await projectManagementCommandSnapshot({ includeRaw: true }); const requestedSourceId = commandValue(command, ["sourceId", "mdtodoSourceId"]); const requestedFileRef = commandValue(command, ["fileRef"]); const requestedFilename = commandValue(command, ["filename", "fileName"]); const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null; const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null; const needsMdtodoSelection = Boolean(requestedFileRef || requestedFilename || requestedTaskRef || requestedTaskId); const sourceSelection = requestedSourceId && initialProject.selectedSourceIdRaw !== requestedSourceId ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: requestedSourceId }) : !requestedSourceId && needsMdtodoSelection && !initialProject.selectedSourceIdRaw ? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: "" }) : null; const fileProject = sourceSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : initialProject; const fileSelection = (requestedFileRef && fileProject.selectedFileRefRaw !== requestedFileRef) || requestedFilename ? await selectMdtodoFile({ ...command, type: "selectMdtodoFile", fileRef: requestedFileRef || command.fileRef, filename: requestedFilename || command.filename }) : null; const taskProject = fileSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : fileProject; const taskSelection = (requestedTaskRef && taskProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId ? await selectMdtodoTask({ ...command, type: "selectMdtodoTask", taskRef: requestedTaskRef || command.taskRef, taskId: requestedTaskId || command.taskId }) : null; const providerSelection = await selectMdtodoProviderProfileForLaunch(command); const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true }); const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", textHash: element.textContent ? null : null, testId: element.getAttribute("data-testid") || null, action: element.getAttribute("data-action") || null, valuesRedacted: true })).catch((error) => ({ disabled: null, error: errorSummary(error), valuesRedacted: true })); if (buttonState.disabled === true) { const error = new Error("launchWorkbenchFromTask button is disabled"); error.details = { beforeUrl, project: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, valuesRedacted: true }; throw error; } const launchPath = projectManagement.launchRoute; const launchResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return new URL(response.url()).pathname === launchPath; } catch { return false; } }, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) })); const chatResponsePromise = page.waitForResponse((response) => { const request = response.request(); if (request.method().toUpperCase() !== "POST") return false; try { return new URL(response.url()).pathname === "/v1/agent/chat"; } catch { return false; } }, { timeout: 30000 }).then(async (response) => { let chatPayload = null; let chatPayloadError = null; try { chatPayload = await response.json(); } catch (error) { chatPayloadError = errorSummary(error); } const headers = response.headers(); return { observed: true, status: response.status(), statusText: response.statusText(), path: new URL(response.url()).pathname, sessionId: sessionIdFromAgentSessionPayload(chatPayload), traceId: traceIdFromAgentChatPayload(chatPayload), otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null, responseParsed: chatPayload !== null, responseParseError: chatPayloadError, valuesRedacted: true }; }).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true })); await button.click(); const launchResponse = await launchResponsePromise; if (launchResponse?.waitError) { const error = new Error("launchWorkbenchFromTask did not observe POST " + launchPath + " response after button click"); error.details = { beforeUrl, afterUrl: currentPageUrl(), launchPath, project: sanitizeProjectCommandSnapshot(projectBeforeClick), waitError: launchResponse.waitError, valuesRedacted: true }; throw error; } const launchStatus = launchResponse.status(); const headers = launchResponse.headers(); const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null; let payload = null; let responseParseError = null; try { payload = await launchResponse.json(); } catch (error) { responseParseError = errorSummary(error); } const sessionId = sessionIdFromAgentSessionPayload(payload); const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || ""); const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null; if (launchStatus < 200 || launchStatus >= 300 || !sessionId) { const error = new Error("launchWorkbenchFromTask did not receive a successful authoritative Workbench session"); error.details = { beforeUrl, afterUrl: currentPageUrl(), launchStatus, statusText: launchResponse.statusText(), contractVersion, responseParsed: payload !== null, responseParseError, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, valuesRedacted: true }; throw error; } if (workbenchUrl) { await page.waitForFunction((expectedPath) => window.location.pathname === expectedPath, workbenchUrl, { timeout: 20000 }).catch(() => null); } const chat = await chatResponsePromise; await page.waitForTimeout(1500); const workbenchSnapshot = await workbenchSessionSnapshot(); return { beforeUrl, afterUrl: currentPageUrl(), launchPath, launchStatus, statusText: launchResponse.statusText(), contractVersion, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, chatObserved: chat?.observed === true, chatStatus: chat?.status ?? null, chatSessionId: chat?.sessionId ?? null, chatTraceId: chat?.traceId ?? null, chatOtelTraceId: chat?.otelTraceId ?? null, chat, workbenchSnapshot, selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw), selection: { source: sourceSelection, file: fileSelection, task: taskSelection, provider: providerSelection, valuesRedacted: true }, initialProject: sanitizeProjectCommandSnapshot(initialProject), projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, responseParsed: payload !== null, responseParseError, pageId, valuesRedacted: true }; } async function launchWorkbenchFromMdtodo(command) { return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" }); } async function projectManagementCommandSnapshot(options = {}) { const raw = await page.evaluate(() => { const visible = (element) => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const text = (element) => String(element?.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240); const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected'); const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected'); const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected'); const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]'); const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]'); const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0; const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0; const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({ value: option.value, label: text(option), selected: option.selected === true })) : []; const selectedFileOption = fileOptions.find((option) => option.selected) || null; const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); const reportError = document.querySelector('[data-testid="mdtodo-report-error"]'); const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); return { path: window.location.pathname, pageKind: visible(document.querySelector('[data-testid="project-management-mdtodo"]')) ? "project-management-mdtodo" : visible(document.querySelector('[data-testid="project-management-root"]')) ? "project-management-root" : null, sourceCount: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount), fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount), taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length, selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null, selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null, selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null, fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20), selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null, selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null, selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, sourceSelectVisible: visible(sourceSelect), fileSelectVisible: visible(fileSelect), sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), taskBodyVisible: visible(bodyRendered), taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "", newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), reportLinkCount: reportLinks.length, reportPreviewVisible: visible(reportPreview), reportPreviewText: visible(reportPreview) ? text(reportPreview) : "", reportErrorVisible: visible(reportError), reportErrorText: visible(reportError) ? text(reportError) : "", reportFullscreenVisible: visible(reportFullscreen), launchButtonVisible: visible(launch), launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", launchButtonText: text(launch), blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8), workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length, valuesRedacted: true }; }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); if (options.includeRaw === true) return raw; return sanitizeProjectCommandSnapshot(raw); } function sanitizeProjectCommandSnapshot(value) { if (!value || typeof value !== "object") return value; const textDigest = (raw, limit = 160) => { const text = String(raw || ""); return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true }; }; const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim()); const suspiciousFileLabel = (label) => { const text = String(label || "").trim(); return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text))); }; const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : []; return { ...value, selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw), selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw), selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw), selectedSourceIdRaw: undefined, selectedFileRefRaw: undefined, selectedTaskRefRaw: undefined, selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null, selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null, fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)), fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length, fileOptionLabels: undefined, taskBodyText: undefined, taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null, reportPreviewText: undefined, reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null, reportErrorText: undefined, reportError: value.reportErrorText ? textDigest(value.reportErrorText, 200) : null, launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null, launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null, launchButtonText: undefined, blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [], valuesRedacted: true }; } function opaqueIdSummary(value) { const text = String(value || ""); if (!text) return null; return { hash: sha256Text(text), preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5), bytes: Buffer.byteLength(text), valuesRedacted: true }; } `; }