Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts
T

1494 lines
74 KiB
TypeScript

// 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
};
}
`;
}