508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable.
|
|
// Responsibility: Fake-server browser regression for project root and MDTODO pages.
|
|
|
|
import { expect, saveScreenshot, test } from "../fixtures/test";
|
|
|
|
test.describe("project management pages", () => {
|
|
test("renders project root and launches Workbench through public DTOs", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.goto("/projects");
|
|
await expect(page.getByTestId("project-management-root")).toBeVisible();
|
|
await expect(page.getByTestId("project-management-summary")).toContainText(
|
|
"Projects",
|
|
);
|
|
await expect(page.getByTestId("project-list")).toContainText(
|
|
"HWLAB MDTODO",
|
|
);
|
|
await page
|
|
.getByRole("link", { name: /HWLAB MDTODO/u })
|
|
.first()
|
|
.click();
|
|
await expect(page).toHaveURL(/\/projects\/project_hwlab_v03/u);
|
|
await expect(page.locator(".project-inspector")).toContainText(
|
|
"mdtodo-file-tree",
|
|
);
|
|
await expect(page.getByText("[redacted]")).toHaveCount(0);
|
|
|
|
await page.getByTestId("open-mdtodo-page").click();
|
|
await expect(page).toHaveURL(/\/projects\/mdtodo/u);
|
|
await expect(page.getByTestId("project-management-mdtodo")).toBeVisible();
|
|
await expect(page.getByTestId("mdtodo-toolbar")).toBeVisible();
|
|
await expect(page.getByTestId("mdtodo-source-select")).toHaveValue(
|
|
"hwlab-v03-mdtodo",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-file-select")).toHaveValue(
|
|
"file_plan",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-file-select")).toContainText(
|
|
"hwlab-v03-mdtodo-web-sample.md",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-file-select")).toContainText(
|
|
"20260609_频率判断_用户反馈.md",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-file-select")).not.toContainText(
|
|
"Task_Report",
|
|
);
|
|
const workspaceBottomGap = await page
|
|
.getByTestId("project-management-mdtodo")
|
|
.evaluate((shell) => {
|
|
const workspace = shell.querySelector(".mdtodo-workspace");
|
|
if (!workspace) return Number.POSITIVE_INFINITY;
|
|
const shellRect = shell.getBoundingClientRect();
|
|
const workspaceRect = workspace.getBoundingClientRect();
|
|
return Math.abs(shellRect.bottom - workspaceRect.bottom);
|
|
});
|
|
expect(workspaceBottomGap).toBeLessThanOrEqual(2);
|
|
const outlineRatio = await page.locator(".task-index").evaluate((panel) => {
|
|
const workspace = panel.closest(".mdtodo-workspace");
|
|
if (!workspace) return 0;
|
|
return (
|
|
panel.getBoundingClientRect().width /
|
|
workspace.getBoundingClientRect().width
|
|
);
|
|
});
|
|
expect(outlineRatio).toBeGreaterThan(0.24);
|
|
expect(outlineRatio).toBeLessThan(0.36);
|
|
await expect(page.getByTestId("mdtodo-task-tree")).toContainText(
|
|
"实现项目根导航",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText(
|
|
"已载入",
|
|
);
|
|
await expect(page.locator('[data-task-id="R1"]')).toBeVisible();
|
|
await expect(page.locator('[data-task-id="R1"]')).toHaveAttribute(
|
|
"data-task-ref",
|
|
"mdtodo:hwlab-v03-mdtodo:file_plan:R1",
|
|
);
|
|
await expect(page.locator('[data-task-id="R1.1"]')).toBeVisible();
|
|
await expect(page.locator('[data-task-id="R1.1.1"]')).toBeVisible();
|
|
await expect(page.getByTestId("mdtodo-task-detail")).not.toContainText(
|
|
"mdtodo:hwlab-v03-mdtodo:file_plan:R1",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-body-read")).toContainText(
|
|
"Root notes",
|
|
);
|
|
await page.getByTestId("mdtodo-report-link").click();
|
|
await expect(page.getByTestId("mdtodo-report-preview")).toContainText(
|
|
"Report body from fake server",
|
|
);
|
|
await expect(page).toHaveURL(
|
|
/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_plan\/tasks\/R1\/reports\/lnk_r11_report/u,
|
|
);
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/files\/file_plan\/tasks\/R1(?:\?|$)/u);
|
|
await page.goForward();
|
|
await expect(page.getByTestId("mdtodo-report-preview")).toContainText(
|
|
"Report body from fake server",
|
|
);
|
|
await page.getByTestId("mdtodo-report-fullscreen").click();
|
|
await expect(
|
|
page.getByTestId("mdtodo-report-fullscreen-dialog"),
|
|
).toContainText("R1.1 implementation report");
|
|
await page
|
|
.locator('[role="dialog"]')
|
|
.filter({ has: page.getByTestId("mdtodo-report-fullscreen-dialog") })
|
|
.getByLabel("关闭", { exact: true })
|
|
.click();
|
|
await page.getByTestId("mdtodo-info-open").click();
|
|
await expect(page.getByTestId("mdtodo-info-popover")).toContainText(
|
|
"Source",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-info-popover")).toContainText(
|
|
"Task / Window",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-info-popover")).toContainText(
|
|
"Task Ref",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-info-popover")).toContainText(
|
|
"mdtodo:hwlab-v03-mdtodo:file_plan:R1",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-info-copy")).toBeVisible();
|
|
await page
|
|
.getByRole("dialog", { name: "MDTODO 信息" })
|
|
.getByLabel("关闭", { exact: true })
|
|
.click();
|
|
|
|
await page.getByTestId("mdtodo-file-select").selectOption("file_feedback");
|
|
await expect(page).toHaveURL(
|
|
/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_feedback\/tasks\/R1/u,
|
|
);
|
|
await expect(page.getByTestId("mdtodo-title-read")).toContainText(
|
|
"这是用户的反馈",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-body-read")).toContainText(
|
|
"sys_trip",
|
|
);
|
|
await page.getByTestId("mdtodo-report-link").click();
|
|
await expect(page.getByTestId("mdtodo-report-preview")).toContainText(
|
|
"int_trip",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-report-preview")).toContainText(
|
|
"sys_trip",
|
|
);
|
|
const reportPreBg = await page
|
|
.locator('[data-testid="mdtodo-report-preview"] pre')
|
|
.first()
|
|
.evaluate((node) => getComputedStyle(node).backgroundColor);
|
|
expect(reportPreBg).not.toBe("rgb(15, 23, 42)");
|
|
await page.getByTestId("mdtodo-file-select").selectOption("file_plan");
|
|
await expect(page).toHaveURL(
|
|
/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_plan\/tasks\/R1/u,
|
|
);
|
|
await expect(page.getByTestId("mdtodo-title-read")).toContainText(
|
|
"实现项目根导航",
|
|
);
|
|
|
|
await page.getByTestId("mdtodo-source-config-open").click();
|
|
await expect(page.getByTestId("mdtodo-source-config-dialog")).toBeVisible();
|
|
await expect(page.getByTestId("mdtodo-source-form-hwpod")).toHaveValue(
|
|
"constart-71freq-c",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-source-form-root")).toHaveValue(
|
|
"docs/MDTODO/",
|
|
);
|
|
await page.getByTestId("mdtodo-source-form-id").fill("draft-source");
|
|
await expect(
|
|
page.getByTestId("mdtodo-source-reindex-dialog"),
|
|
).toBeDisabled();
|
|
await page.getByTestId("mdtodo-source-form-id").fill("hwlab-v03-mdtodo");
|
|
await expect(
|
|
page.getByTestId("mdtodo-source-reindex-dialog"),
|
|
).toBeEnabled();
|
|
await page.getByTestId("mdtodo-source-probe").click();
|
|
await expect(page.getByTestId("mdtodo-source-message")).toContainText(
|
|
"Probe ok",
|
|
);
|
|
await page.getByTestId("mdtodo-source-reindex-dialog").click();
|
|
await expect(page.getByTestId("mdtodo-source-message")).toContainText(
|
|
"Reindex 3 files / 5 tasks",
|
|
);
|
|
await page
|
|
.getByRole("dialog", { name: "MDTODO Source" })
|
|
.getByLabel("关闭", { exact: true })
|
|
.click();
|
|
await page.getByTestId("mdtodo-title-read").dblclick();
|
|
await page.getByTestId("mdtodo-edit-title").fill("实现项目根导航 - edited");
|
|
await page.getByTestId("mdtodo-edit-status").selectOption("in_progress");
|
|
await page.getByTestId("mdtodo-edit-save").click();
|
|
await expect(page.getByTestId("mdtodo-task-notice")).toContainText(
|
|
"任务已保存",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-task-detail")).toContainText(
|
|
"实现项目根导航 - edited",
|
|
);
|
|
await expect(page.locator('[data-task-id="R1"]')).toContainText("进行中");
|
|
await page.getByTestId("mdtodo-body-read").dblclick();
|
|
await page
|
|
.getByTestId("mdtodo-body-editor")
|
|
.fill("Body saved from Playwright.");
|
|
await page.getByTestId("mdtodo-body-save").click();
|
|
await expect(page.getByTestId("mdtodo-task-notice")).toContainText(
|
|
"正文已保存",
|
|
);
|
|
await page.getByTestId("mdtodo-new-task-open").click();
|
|
await expect(page.getByTestId("mdtodo-task-create-dialog")).toBeVisible();
|
|
await page.getByTestId("mdtodo-new-title").fill("Playwright child");
|
|
await page.getByTestId("mdtodo-new-body").fill("Child from E2E.");
|
|
await page.getByTestId("mdtodo-add-subtask").click();
|
|
await expect(page.getByTestId("mdtodo-task-notice")).toContainText(
|
|
"任务已创建",
|
|
);
|
|
await expect(page).toHaveURL(/\/tasks\/R1\.2/u);
|
|
await expect(page.locator('[data-task-id="R1.2"]')).toContainText(
|
|
"Playwright child",
|
|
);
|
|
await page.getByTestId("mdtodo-new-task-open").click();
|
|
await page.getByTestId("mdtodo-new-title").fill("Playwright sibling");
|
|
await page.getByTestId("mdtodo-continue-task").click();
|
|
await expect(page.locator('[data-task-id="R1.3"]')).toContainText(
|
|
"Playwright sibling",
|
|
);
|
|
await page.getByTestId("mdtodo-delete-task").click();
|
|
await expect(page.getByTestId("mdtodo-task-notice")).toContainText(
|
|
"再次点击确认删除",
|
|
);
|
|
await page.getByTestId("mdtodo-delete-task").click();
|
|
await expect(page.getByTestId("mdtodo-task-notice")).toContainText(
|
|
"任务已删除",
|
|
);
|
|
await expect(page).toHaveURL(/\/tasks\/R1(?:\?|$)/u);
|
|
await expect(page.locator('[data-task-id="R1.3"]')).toHaveCount(0);
|
|
await page.getByTestId("mdtodo-info-open").click();
|
|
await expect(page.getByTestId("mdtodo-info-popover")).toContainText(
|
|
"ses_project_seed",
|
|
);
|
|
await page
|
|
.getByRole("dialog", { name: "MDTODO 信息" })
|
|
.getByLabel("关闭", { exact: true })
|
|
.click();
|
|
await expect(page.getByTestId("mdtodo-launch-workbench")).toBeEnabled();
|
|
await expect(page.locator("iframe")).toHaveCount(0);
|
|
await saveScreenshot(page, testInfo, "project-management-mdtodo");
|
|
|
|
const workbenchPagePromise = page.waitForEvent("popup");
|
|
await page.getByTestId("mdtodo-launch-workbench").click();
|
|
const workbenchPage = await workbenchPagePromise;
|
|
await expect(workbenchPage).toHaveURL(
|
|
/\/workbench\/sessions\/ses_project_launch_/u,
|
|
);
|
|
await expect(workbenchPage.locator("#workspace")).toBeVisible();
|
|
const sessionId =
|
|
workbenchPage.url().match(/\/workbench\/sessions\/([^/?#]+)/u)?.[1] ?? "";
|
|
expect(sessionId).toMatch(/^ses_project_launch_/u);
|
|
|
|
const sessionDetail = await page.request.get(
|
|
`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`,
|
|
);
|
|
expect(sessionDetail.ok()).toBeTruthy();
|
|
const sessionPayload = await sessionDetail.json();
|
|
expect(
|
|
sessionPayload.session?.launchContext?.projectId ??
|
|
sessionPayload.session?.metadata?.launchContext?.projectId,
|
|
).toBe("project_hwlab_v03");
|
|
expect(
|
|
sessionPayload.session?.launchContext?.taskRef ??
|
|
sessionPayload.session?.metadata?.launchContext?.taskRef,
|
|
).toBe("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
|
|
expect(
|
|
sessionPayload.session?.messageCount ??
|
|
sessionPayload.session?.messages?.length ??
|
|
0,
|
|
).toBeGreaterThan(0);
|
|
expect(sessionPayload.session?.firstUserMessagePreview ?? "").toContain(
|
|
"MDTODO",
|
|
);
|
|
|
|
const links = await page.request.get(
|
|
"/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:R1",
|
|
);
|
|
expect(links.ok()).toBeTruthy();
|
|
const linksPayload = await links.json();
|
|
expect(
|
|
linksPayload.links.some(
|
|
(link: { sessionId?: string }) => link.sessionId === sessionId,
|
|
),
|
|
).toBeTruthy();
|
|
await saveScreenshot(
|
|
workbenchPage,
|
|
testInfo,
|
|
"project-management-workbench-launch",
|
|
);
|
|
});
|
|
|
|
test("restores task 205 deep link and incrementally loads the bounded task tree", async ({
|
|
page,
|
|
}) => {
|
|
const longTasks = Array.from({ length: 205 }, (_, index) => {
|
|
const taskId = `R${index + 1}`;
|
|
return {
|
|
taskRef: `mdtodo:hwlab-v03-mdtodo:file_long:${taskId}`,
|
|
projectId: "project_hwlab_v03",
|
|
sourceId: "hwlab-v03-mdtodo",
|
|
fileRef: "file_long",
|
|
taskId,
|
|
title: `Long tree task ${index + 1}`,
|
|
status: index === 204 ? "in_progress" : "open",
|
|
parentTaskRef: null,
|
|
depth: 0,
|
|
ordinal: index + 1,
|
|
bodyPreview: `Long tree body ${index + 1}`,
|
|
sourceFingerprint: "sha256:e2e-long",
|
|
};
|
|
});
|
|
await page.route(
|
|
"**/v1/project-management/mdtodo/files?**",
|
|
async (route) => {
|
|
const upstream = await route.fetch();
|
|
const body = (await upstream.json()) as {
|
|
files?: Array<Record<string, unknown>>;
|
|
};
|
|
await route.fulfill({
|
|
response: upstream,
|
|
json: {
|
|
...body,
|
|
files: [
|
|
...(body.files ?? []),
|
|
{
|
|
sourceId: "hwlab-v03-mdtodo",
|
|
fileRef: "file_long",
|
|
projectId: "project_hwlab_v03",
|
|
relativePath: "docs/MDTODO/long-tree.md",
|
|
title: "205 task fixture",
|
|
taskCount: 205,
|
|
fingerprint: "sha256:e2e-long",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
await page.route(
|
|
"**/v1/project-management/mdtodo/tasks?**",
|
|
async (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.searchParams.get("fileRef") !== "file_long")
|
|
return route.continue();
|
|
const exactRef = url.searchParams.get("taskRef");
|
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
|
const status = url.searchParams.get("status");
|
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
const limit = Number(url.searchParams.get("limit") ?? 100);
|
|
const matched = longTasks.filter(
|
|
(task) =>
|
|
(!exactRef || task.taskRef === exactRef) &&
|
|
(!search ||
|
|
[task.taskId, task.title, task.taskRef].some((value) =>
|
|
value.toLowerCase().includes(search),
|
|
)) &&
|
|
(!status || task.status === status),
|
|
);
|
|
const tasks = matched.slice(offset, offset + limit);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
json: {
|
|
ok: true,
|
|
contractVersion: "project-management-v1",
|
|
serviceId: "hwlab-project-management",
|
|
tasks,
|
|
page: {
|
|
offset,
|
|
limit,
|
|
maxLimit: 500,
|
|
total: matched.length,
|
|
hasMore: offset + tasks.length < matched.length,
|
|
},
|
|
valuesRedacted: true,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
await page.route(
|
|
"**/v1/project-management/mdtodo/task-detail?**",
|
|
async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const task = longTasks.find(
|
|
(item) => item.taskRef === url.searchParams.get("taskRef"),
|
|
);
|
|
if (!task) return route.continue();
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
json: {
|
|
ok: true,
|
|
contractVersion: "project-management-v1",
|
|
serviceId: "hwlab-project-management",
|
|
task,
|
|
detail: {
|
|
...task,
|
|
body: task.bodyPreview,
|
|
links: [],
|
|
valuesRedacted: false,
|
|
},
|
|
valuesRedacted: true,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.goto(
|
|
"/projects/mdtodo/sources/hwlab-v03-mdtodo/files/file_long/tasks/R205",
|
|
);
|
|
await expect(page).toHaveURL((url) =>
|
|
url.pathname.endsWith("/files/file_long/tasks/R205"),
|
|
);
|
|
await expect(page.getByTestId("mdtodo-title-read")).toContainText(
|
|
"Long tree task 205",
|
|
);
|
|
await expect(page.locator('[data-task-id="R205"]')).toBeVisible();
|
|
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText(
|
|
"101 / 205",
|
|
);
|
|
await page.getByTestId("mdtodo-load-more").click();
|
|
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText(
|
|
"201 / 205",
|
|
);
|
|
await page.getByTestId("mdtodo-load-more").click();
|
|
await expect(page.getByTestId("mdtodo-load-more")).toHaveCount(0);
|
|
await expect(page.locator('[data-task-id="R204"]')).toBeVisible();
|
|
await expect(page).toHaveURL(
|
|
(url) =>
|
|
url.pathname.endsWith("/files/file_long/tasks/R205") &&
|
|
url.searchParams.get("provider") === "codex-api",
|
|
);
|
|
});
|
|
|
|
test("keeps an offline HWPOD projection read-only without requesting live reports", async ({
|
|
page,
|
|
}) => {
|
|
let reportPreviewRequests = 0;
|
|
await page.route(
|
|
"**/v1/project-management/mdtodo/task-detail?**",
|
|
async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
json: {
|
|
ok: true,
|
|
contractVersion: "project-management-v1",
|
|
serviceId: "hwlab-project-management",
|
|
status: "partial",
|
|
detail: null,
|
|
detailAuthority: {
|
|
state: "unavailable",
|
|
authority: "project-management-projection",
|
|
sourceKind: "hwpod-workspace",
|
|
code: "hwpod_node_offline",
|
|
message:
|
|
"HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。",
|
|
retryable: true,
|
|
valuesRedacted: true,
|
|
},
|
|
valuesRedacted: true,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
await page.route(
|
|
"**/v1/project-management/mdtodo/report-preview?**",
|
|
async (route) => {
|
|
reportPreviewRequests += 1;
|
|
await route.fulfill({
|
|
status: 503,
|
|
contentType: "application/json",
|
|
json: {
|
|
ok: false,
|
|
code: "hwpod_node_offline",
|
|
message: "must not be requested",
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.goto(
|
|
"/projects/mdtodo/sources/hwlab-v03-mdtodo/files/file_plan/tasks/R1",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-task-detail")).toContainText(
|
|
"offline projection",
|
|
);
|
|
await expect(page.getByTestId("mdtodo-title-read")).toBeDisabled();
|
|
await expect(page.getByTestId("mdtodo-edit-status")).toBeDisabled();
|
|
await expect(page.getByTestId("mdtodo-delete-task")).toBeDisabled();
|
|
await expect(page.getByTestId("mdtodo-new-task-open")).toBeDisabled();
|
|
await expect(page.getByTestId("mdtodo-create-blocker")).toContainText(
|
|
"离线投影",
|
|
);
|
|
await page.getByTestId("mdtodo-body-read").dblclick({ force: true });
|
|
await expect(page.getByTestId("mdtodo-body-editor")).toHaveCount(0);
|
|
|
|
await expect(page.getByTestId("mdtodo-report-link")).toBeDisabled();
|
|
await expect(page.getByTestId("mdtodo-task-detail")).toContainText(
|
|
/报告索引|Node 恢复/u,
|
|
);
|
|
expect(reportPreviewRequests).toBe(0);
|
|
await expect(page.getByTestId("mdtodo-launch-workbench")).toBeEnabled();
|
|
});
|
|
});
|