fix(web-probe): 对齐新版 MDTODO DOM 合同

This commit is contained in:
Codex
2026-07-13 14:23:05 +02:00
parent c5e8f163b5
commit cd7fb392f6
4 changed files with 114 additions and 28 deletions
@@ -7,10 +7,32 @@ import {
} from "./hwlab-node-web-observe-runner-sampling-source";
test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => {
assert.match(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, /\[data-testid="mdtodo-task-tree-facts"\]/u);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-tree-facts"\]/u,
);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-page-fact"\]/u,
);
const source = nodeWebObserveRunnerSamplingSource();
assert.ok(source.includes(`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`));
assert.ok(
source.includes(
`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`,
),
);
assert.match(
source,
/semanticTaskCandidates\.length > 0 \? semanticTaskCandidates : fallbackTaskCandidates/u,
);
assert.match(
source,
/taskCandidates\.filter\(\(candidate\) => !taskRefElement\(candidate\)\)\.length/u,
);
assert.doesNotMatch(source, /taskCandidates\.length - taskItems\.length/u);
assert.match(source, /mdtodo-body-read/u);
assert.match(source, /mdtodo-launch-workbench/u);
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
@@ -19,6 +41,9 @@ test("MDTODO task tree pane gap treats the pinned facts footer as content", asyn
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
@@ -3,6 +3,7 @@
export const MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR = [
'[data-testid="mdtodo-task-tree-facts"]',
'[data-testid="mdtodo-task-page-fact"]',
"[data-task-ref]",
'[role="treeitem"]',
'[role="listitem"]',
@@ -433,22 +434,32 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
selected: option.selected === true,
})) : [];
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
const taskTree = document.querySelector('[data-testid="mdtodo-task-tree"]');
const semanticTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll('[role="treeitem"], [role="listitem"]')).filter(visible)
: [];
const fallbackTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll("li")).filter(visible)
: [];
const taskCandidates = semanticTaskCandidates.length > 0 ? semanticTaskCandidates : fallbackTaskCandidates;
const taskRefElement = (candidate) => candidate.matches("[data-task-ref]")
? candidate
: candidate.querySelector("[data-task-ref]");
const taskItems = Array.from(new Set(taskCandidates.map(taskRefElement).filter(visible)));
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 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 statusCounts = {};
for (const task of taskItems) {
const status = task.getAttribute("data-task-status") || "unknown";
const status = task.getAttribute("data-task-status") || task.querySelector("[data-state]")?.getAttribute("data-state") || "unknown";
statusCounts[status] = (statusCounts[status] || 0) + 1;
}
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
index,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
@@ -475,7 +486,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
};
const paneGaps = [
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)}),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-body-read"], [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .document-header, .task-status-stack > *, .task-document-footer, .document-footer'),
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
];
return {
@@ -486,17 +497,17 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
fileCount: Math.max(fileItems.length, fileOptionCount),
taskCount: taskItems.length,
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
taskRefMissingCount: taskCandidates.filter((candidate) => !taskRefElement(candidate)).length,
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null,
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24),
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || 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"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source";
test("MDTODO observe commands recognize the rewritten workspace contracts", async () => {
const source = nodeWebObserveRunnerWorkbenchSource();
for (const testId of [
"mdtodo-body-read",
"mdtodo-body-editor",
"mdtodo-body-save",
"mdtodo-launch-workbench",
]) {
assert.match(source, new RegExp(testId, "u"));
}
for (const legacyTestId of [
"mdtodo-body-rendered",
"mdtodo-edit-body",
"mdtodo-edit-body-save",
"mdtodo-workbench-launch",
]) {
assert.match(source, new RegExp(legacyTestId, "u"));
}
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
@@ -764,7 +764,7 @@ async function selectMdtodoTask(command) {
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,
status: element.getAttribute("data-task-status") || element.querySelector("[data-state]")?.getAttribute("data-state") || 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) {
@@ -895,12 +895,22 @@ async function closeMdtodoSourceConfigIfOpen(options = {}) {
return result;
}
function mdtodoTestIdCandidates(value) {
return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && item.length > 0);
}
function mdtodoTestIdLocator(value) {
const candidates = mdtodoTestIdCandidates(value);
return page.locator(candidates.map((testId) => '[data-testid="' + cssEscape(testId) + '"]').join(", ")).first();
}
async function fillMdtodoField(testId, value) {
if (typeof value !== "string" || !value.trim()) return { testId, filled: false };
const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
const locator = mdtodoTestIdLocator(testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await locator.fill(value);
return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
const resolvedTestId = await locator.getAttribute("data-testid").catch(() => null);
return { testId: resolvedTestId || mdtodoTestIdCandidates(testId)[0] || null, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
}
async function clickProjectButtonAndMaybeWait(testId, pathPattern) {
@@ -1003,7 +1013,7 @@ async function saveMdtodoTaskWithButton(command, type, testId, 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();
const locator = mdtodoTestIdLocator(field.testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await selectHtmlOptionByValueOrLabel(locator, field.value);
}
@@ -1023,9 +1033,9 @@ async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) {
}
async function ensureMdtodoInlineEditor(editorTestId, readTestId) {
const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first();
const editor = mdtodoTestIdLocator(editorTestId);
if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false };
const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first();
const read = mdtodoTestIdLocator(readTestId);
await read.waitFor({ state: "visible", timeout: 10000 });
await read.dblclick();
await editor.waitFor({ state: "visible", timeout: 10000 });
@@ -1041,7 +1051,7 @@ async function editMdtodoTaskTitle(command) {
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" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
async function toggleMdtodoTaskStatus(command) {
@@ -1060,7 +1070,7 @@ async function editMdtodoTaskInline(command) {
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" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
if (field === "status") {
const status = commandValue(command, ["status", "text"]);
@@ -1219,7 +1229,9 @@ async function deleteMdtodoTask(command) {
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());
const deleteButton = page.locator('[data-testid="mdtodo-delete-task"]').first();
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first())
|| await deleteButton.evaluate((element) => /确认|confirm/iu.test(String(element.textContent || ""))).catch(() => false);
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 };
}
@@ -1250,7 +1262,7 @@ async function launchWorkbenchFromTask(command) {
: 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();
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [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",
@@ -1392,8 +1404,8 @@ async function projectManagementCommandSnapshot(options = {}) {
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 launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
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"]');
@@ -1410,11 +1422,11 @@ async function projectManagementCommandSnapshot(options = {}) {
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,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || 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"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -1427,7 +1439,7 @@ async function projectManagementCommandSnapshot(options = {}) {
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),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [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
};