539 lines
26 KiB
TypeScript
539 lines
26 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { expect, test } from "bun:test";
|
|
|
|
import { executeHwpodNodeOpsPlan } from "../../tools/src/hwpod-node-lib.ts";
|
|
import { createProjectManagementApp } from "./server.ts";
|
|
import { createMdtodoSourceAdapter, normalizeProjectSourceInput } from "./source-adapter.ts";
|
|
import { createProjectManagementStore } from "./store.ts";
|
|
|
|
test("project management source adapter types HWPOD transport failures", async () => {
|
|
const normalized = normalizeProjectSourceInput({
|
|
sourceId: "transport-hwpod-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
projectId: "project_transport",
|
|
hwpodId: "transport-hwpod",
|
|
nodeId: "transport-node",
|
|
workspaceRootRef: "C:/Work/Transport",
|
|
mdtodoRootRef: "docs/MDTODO"
|
|
});
|
|
expect(normalized.ok).toBe(true);
|
|
const adapter = createMdtodoSourceAdapter({
|
|
env: { HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: "http://hwpod.invalid/node-ops" },
|
|
fetchImpl: async () => { throw new TypeError("connection refused with internal endpoint"); }
|
|
});
|
|
let caught;
|
|
try {
|
|
await adapter.readFile(normalized.source, "MDTODO.md");
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toMatchObject({
|
|
code: "hwpod_transport_unavailable",
|
|
statusCode: 502,
|
|
sourceKind: "hwpod-workspace",
|
|
message: "HWPOD node-ops transport is unavailable"
|
|
});
|
|
expect(String(caught?.message)).not.toContain("internal endpoint");
|
|
});
|
|
|
|
test("project management app exposes MDTODO read model and Workbench link writes", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-"));
|
|
try {
|
|
await writeFile(join(root, "MDTODO.md"), "# Demo\n\n## R1 Launch project nav\n\n## R2 Wire read model [completed]\n", "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: {},
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: root,
|
|
sourceId: "test-mdtodo",
|
|
projectId: "project_test"
|
|
});
|
|
|
|
const ready = await app.fetch(new Request("http://service/health/ready"));
|
|
expect(ready.status).toBe(200);
|
|
|
|
const navigation = await json(app, "/v1/project-management/navigation");
|
|
expect(navigation.navigation.id).toBe("project-management");
|
|
|
|
const sources = await json(app, "/v1/project-management/mdtodo/sources");
|
|
expect(sources.sources[0].sourceId).toBe("test-mdtodo");
|
|
|
|
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=test-mdtodo");
|
|
expect(files.files[0].taskCount).toBe(2);
|
|
|
|
const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo");
|
|
expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
|
|
expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "completed"]);
|
|
expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);
|
|
expect(tasks.page).toMatchObject({ offset: 0, limit: 200, total: 2, hasMore: false });
|
|
|
|
const taskWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo&limit=1&offset=1");
|
|
expect(taskWindow.tasks.map((task) => task.taskId)).toEqual(["R2"]);
|
|
expect(taskWindow.page).toMatchObject({ offset: 1, limit: 1, total: 2, hasMore: false });
|
|
|
|
const projectActivity = await json(app, "/v1/project-management/mdtodo/tasks?projectId=project_test&limit=1");
|
|
expect(projectActivity.tasks).toHaveLength(1);
|
|
expect(projectActivity.tasks[0].projectId).toBe("project_test");
|
|
expect(projectActivity.page).toMatchObject({ offset: 0, limit: 1, total: 2, hasMore: true });
|
|
|
|
const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
|
|
expect(links.links).toEqual([]);
|
|
|
|
const linkResponse = await app.fetch(new Request("http://service/v1/project-management/workbench-links", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "x-hwlab-actor-id": "usr_test" },
|
|
body: JSON.stringify({ projectId: "project_test", taskRef: tasks.tasks[0].taskRef, sessionId: "ses_project_test", role: "launch", link: { workbenchUrl: "/workbench/sessions/ses_project_test", launchContext: { projectId: "project_test", taskRef: tasks.tasks[0].taskRef, title: tasks.tasks[0].title } } })
|
|
}));
|
|
expect(linkResponse.status).toBe(201);
|
|
const createdLink = await linkResponse.json();
|
|
expect(createdLink.link.sessionId).toBe("ses_project_test");
|
|
|
|
const linkedTasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo");
|
|
expect(linkedTasks.tasks[0].linkCount).toBe(1);
|
|
|
|
const taskLinks = await json(app, `/v1/project-management/workbench-links?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`);
|
|
expect(taskLinks.links[0].sessionId).toBe("ses_project_test");
|
|
|
|
const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "PUT" }));
|
|
expect(rejected.status).toBe(405);
|
|
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management task list defaults to a bounded window", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-task-window-"));
|
|
try {
|
|
const body = ["# Demo", "", ...Array.from({ length: 5 }, (_, index) => `## R${index + 1} Task ${index + 1}`)].join("\n");
|
|
await writeFile(join(root, "MDTODO.md"), `${body}\n`, "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: {
|
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "2",
|
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "3"
|
|
},
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: root,
|
|
sourceId: "bounded-mdtodo",
|
|
projectId: "project_test"
|
|
});
|
|
|
|
const ready = await app.fetch(new Request("http://service/health/ready"));
|
|
expect(ready.status).toBe(200);
|
|
|
|
const defaultWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo");
|
|
expect(defaultWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
|
|
expect(defaultWindow.page).toMatchObject({ offset: 0, limit: 2, maxLimit: 3, total: 5, hasMore: true });
|
|
|
|
const cappedWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo&limit=99");
|
|
expect(cappedWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2", "R3"]);
|
|
expect(cappedWindow.page).toMatchObject({ offset: 0, limit: 3, maxLimit: 3, total: 5, hasMore: true });
|
|
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management task list restores an exact task beyond the first bounded window", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-deep-task-"));
|
|
try {
|
|
const body = ["# Demo", "", ...Array.from({ length: 205 }, (_, index) => `## R${index + 1} Task ${index + 1}`)].join("\n");
|
|
await writeFile(join(root, "MDTODO.md"), `${body}\n`, "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: {},
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: root,
|
|
sourceId: "deep-mdtodo",
|
|
projectId: "project_test"
|
|
});
|
|
|
|
expect((await app.fetch(new Request("http://service/health/ready"))).status).toBe(200);
|
|
const firstWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=deep-mdtodo&limit=100");
|
|
expect(firstWindow.tasks).toHaveLength(100);
|
|
expect(firstWindow.page).toMatchObject({ offset: 0, limit: 100, total: 205, hasMore: true });
|
|
|
|
const taskRef = `mdtodo:deep-mdtodo:${firstWindow.tasks[0].fileRef}:R205`;
|
|
const exact = await json(app, `/v1/project-management/mdtodo/tasks?sourceId=deep-mdtodo&taskRef=${encodeURIComponent(taskRef)}&limit=1`);
|
|
expect(exact.tasks).toHaveLength(1);
|
|
expect(exact.tasks[0]).toMatchObject({ taskRef, taskId: "R205", title: "Task 205" });
|
|
expect(exact.page).toMatchObject({ offset: 0, limit: 1, total: 1, hasMore: false });
|
|
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management app configures HWPOD source and writes MDTODO content", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-"));
|
|
const defaultRoot = join(root, "default");
|
|
const hwpodRoot = join(root, "hwpod-workspace");
|
|
const mdtodoDir = join(hwpodRoot, "docs", "MDTODO");
|
|
const samplePath = join(mdtodoDir, "hwlab-v03-mdtodo-web-sample.md");
|
|
const reportDir = join(mdtodoDir, "20260110_LOG");
|
|
const reportPath = join(reportDir, "R1.1_log_implementation.md");
|
|
const store = createProjectManagementStore({ kind: "memory" });
|
|
try {
|
|
await mkdir(defaultRoot, { recursive: true });
|
|
await mkdir(mdtodoDir, { recursive: true });
|
|
await mkdir(reportDir, { recursive: true });
|
|
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
|
await writeFile(samplePath, "# Sample\n\n## R1 Root\n\nSee [R1.1](./20260110_LOG/R1.1_log_implementation.md).\n\n### R1.1 Child [in_progress]\n", "utf8");
|
|
await writeFile(reportPath, "# R1.1 implementation report\n\nReport body.", "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: {},
|
|
store,
|
|
sourceRoot: defaultRoot,
|
|
sourceId: "default-mdtodo",
|
|
projectId: "project_test",
|
|
hwpodNodeOpsHandler: (plan) => executeHwpodNodeOpsPlan(plan)
|
|
});
|
|
|
|
const createResponse = await app.fetch(jsonRequest("/v1/project-management/mdtodo/sources", {
|
|
method: "POST",
|
|
body: {
|
|
sourceId: "hwpod-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
displayName: "D601 F103 MDTODO",
|
|
projectId: "project_test",
|
|
hwpodId: "d601-f103-v2",
|
|
nodeId: "node-d601-f103-v2",
|
|
workspaceRootRef: hwpodRoot,
|
|
mdtodoRootRef: "docs/MDTODO/"
|
|
}
|
|
}));
|
|
expect(createResponse.status).toBe(201);
|
|
const created = await createResponse.json();
|
|
expect(created.source.sourceKind).toBe("hwpod-workspace");
|
|
|
|
const probe = await post(app, "/v1/project-management/mdtodo/sources/hwpod-mdtodo/probe", {});
|
|
expect(probe.probe.status).toBe("ok");
|
|
|
|
const reindex = await post(app, "/v1/project-management/mdtodo/sources/hwpod-mdtodo/reindex", {});
|
|
expect(reindex.projection.documentCount).toBe(1);
|
|
expect(reindex.projection.taskCount).toBe(2);
|
|
|
|
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=hwpod-mdtodo");
|
|
expect(files.files).toHaveLength(1);
|
|
expect(files.files[0].relativePath).toBe("hwlab-v03-mdtodo-web-sample.md");
|
|
const fileRef = files.files[0].fileRef;
|
|
|
|
const content = await json(app, `/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`);
|
|
expect(content.file.content).toContain("## R1 Root");
|
|
|
|
const rootTaskRef = `mdtodo:hwpod-mdtodo:${fileRef}:R1`;
|
|
const detail = await json(app, `/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(rootTaskRef)}`);
|
|
expect(detail.detail.body).toContain("See [R1.1]");
|
|
expect(detail.detail.links[0]).toMatchObject({ kind: "markdown-report", relativePath: "20260110_LOG/R1.1_log_implementation.md" });
|
|
const report = await json(app, `/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(rootTaskRef)}&linkId=${encodeURIComponent(detail.detail.links[0].linkId)}`);
|
|
expect(report.report.content).toContain("Report body.");
|
|
|
|
const launchContext = await json(app, `/v1/project-management/mdtodo/launch-context?taskRef=${encodeURIComponent(rootTaskRef)}`);
|
|
expect(launchContext.executionContext).toMatchObject({
|
|
sourceId: "hwpod-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
projectId: "project_test",
|
|
taskRef: rootTaskRef,
|
|
fileRef,
|
|
relativePath: "hwlab-v03-mdtodo-web-sample.md",
|
|
taskId: "R1",
|
|
hwpodId: "d601-f103-v2",
|
|
nodeId: "node-d601-f103-v2",
|
|
mdtodoRootRef: "docs/MDTODO",
|
|
valuesRedacted: true
|
|
});
|
|
expect(launchContext.executionContext.hwpodWorkspaceArgs).toContain("--hwpod-id d601-f103-v2");
|
|
expect(launchContext.executionContext.hwpodWorkspaceArgs).toContain("--workspace-path");
|
|
expect(launchContext.executionContext.workspaceRootHash).toMatch(/^[0-9a-f]{64}$/u);
|
|
expect(launchContext.executionContext.contextFingerprint).toMatch(/^ctx_[0-9a-f]{24}$/u);
|
|
|
|
const linkWithContext = await app.fetch(jsonRequest("/v1/project-management/workbench-links", {
|
|
method: "POST",
|
|
body: { projectId: "project_test", taskRef: rootTaskRef, sessionId: "ses_hwpod_launch", role: "launch", link: { workbenchUrl: "/workbench/sessions/ses_hwpod_launch", launchContext: launchContext.launchContext } }
|
|
}));
|
|
expect(linkWithContext.status).toBe(201);
|
|
const savedLink = await linkWithContext.json();
|
|
expect(savedLink.link.link.launchContext).toMatchObject({
|
|
sourceId: "hwpod-mdtodo",
|
|
hwpodId: "d601-f103-v2",
|
|
mdtodoRootRef: "docs/MDTODO",
|
|
contextFingerprint: launchContext.executionContext.contextFingerprint
|
|
});
|
|
|
|
const nextContent = "# Sample\n\n## R1 Edited root\n\n### R1.1 Child [completed]\n";
|
|
const write = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, {
|
|
method: "PATCH",
|
|
body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent }
|
|
}));
|
|
expect(write.status).toBe(200);
|
|
const written = await write.json();
|
|
expect(written.file.fingerprint).not.toBe(content.file.fingerprint);
|
|
expect(await readFile(samplePath, "utf8")).toBe(nextContent);
|
|
|
|
const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=hwpod-mdtodo");
|
|
expect(tasks.tasks.map((task) => [task.taskId, task.title, task.status])).toEqual([
|
|
["R1", "Edited root", "open"],
|
|
["R1.1", "Child", "completed"]
|
|
]);
|
|
|
|
const updateTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(rootTaskRef)}`, {
|
|
method: "PATCH",
|
|
body: { expectedFingerprint: written.file.fingerprint, title: "Root from task API", status: "in_progress", body: "Task API body." }
|
|
}));
|
|
expect(updateTask.status).toBe(200);
|
|
const updatedTask = await updateTask.json();
|
|
expect(updatedTask.task.title).toBe("Root from task API");
|
|
expect(updatedTask.task.status).toBe("in_progress");
|
|
expect(await readFile(samplePath, "utf8")).toContain("## R1 Root from task API [in_progress]\n\nTask API body.");
|
|
|
|
const addSubtask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", {
|
|
method: "POST",
|
|
body: { parentTaskRef: rootTaskRef, expectedFingerprint: updatedTask.file.fingerprint, title: "Added child", body: "Child body." }
|
|
}));
|
|
expect(addSubtask.status).toBe(201);
|
|
const added = await addSubtask.json();
|
|
expect(added.changedTaskId).toBe("R1.2");
|
|
expect(added.task.taskRef).toBe(`mdtodo:hwpod-mdtodo:${fileRef}:R1.2`);
|
|
|
|
const continueTask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", {
|
|
method: "POST",
|
|
body: { afterTaskRef: `mdtodo:hwpod-mdtodo:${fileRef}:R1.1`, expectedFingerprint: added.file.fingerprint, title: "Continued child" }
|
|
}));
|
|
expect(continueTask.status).toBe(201);
|
|
const continued = await continueTask.json();
|
|
expect(continued.changedTaskId).toBe("R1.3");
|
|
|
|
const deleteTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(added.task.taskRef)}`, {
|
|
method: "DELETE",
|
|
body: { expectedFingerprint: continued.file.fingerprint }
|
|
}));
|
|
expect(deleteTask.status).toBe(200);
|
|
const deleted = await deleteTask.json();
|
|
expect(deleted.affectedTaskIds).toEqual(["R1.2"]);
|
|
expect(await readFile(samplePath, "utf8")).not.toContain("R1.2 Added child");
|
|
|
|
const conflict = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, {
|
|
method: "PATCH",
|
|
body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent }
|
|
}));
|
|
expect(conflict.status).toBe(409);
|
|
|
|
const rejected = await app.fetch(jsonRequest("/v1/project-management/mdtodo/sources", {
|
|
method: "POST",
|
|
body: { sourceKind: "hwpod-workspace", hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", workspaceRootRef: hwpodRoot, mdtodoRootRef: "../secret" }
|
|
}));
|
|
expect(rejected.status).toBe(400);
|
|
expect((await store.listAuditEvents()).some((event) => event.action === "mdtodo.file.write")).toBe(true);
|
|
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management app bootstraps HWPOD sources from a mounted JSON file", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-bootstrap-"));
|
|
const defaultRoot = join(root, "default");
|
|
const hwpodRoot = join(root, "hwpod-workspace");
|
|
const mdtodoDir = join(hwpodRoot, "docs", "MDTODO");
|
|
const configPath = join(root, "sources.json");
|
|
try {
|
|
await mkdir(defaultRoot, { recursive: true });
|
|
await mkdir(mdtodoDir, { recursive: true });
|
|
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
|
await writeFile(join(mdtodoDir, "bootstrap.md"), "# Bootstrap\n\n## R1 From mounted source\n", "utf8");
|
|
await writeFile(configPath, JSON.stringify({
|
|
sources: [{
|
|
sourceId: "constart-71freq-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
displayName: "71-FREQ MDTODO",
|
|
projectId: "project_constart_71freq",
|
|
hwpodId: "constart-71freq-c",
|
|
nodeId: "node-d601-f103-v2",
|
|
workspaceRootRef: hwpodRoot,
|
|
mdtodoRootRef: "docs/MDTODO"
|
|
}]
|
|
}), "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: { HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: configPath },
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: defaultRoot,
|
|
sourceId: "default-mdtodo",
|
|
projectId: "project_test",
|
|
hwpodNodeOpsHandler: (plan) => executeHwpodNodeOpsPlan(plan)
|
|
});
|
|
|
|
const ready = await app.fetch(new Request("http://service/health/ready"));
|
|
expect(ready.status).toBe(200);
|
|
const sources = await json(app, "/v1/project-management/mdtodo/sources");
|
|
expect(sources.sources.map((source) => source.sourceId).sort()).toEqual(["constart-71freq-mdtodo", "default-mdtodo"]);
|
|
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=constart-71freq-mdtodo");
|
|
expect(files.files[0].relativePath).toBe("bootstrap.md");
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management task detail falls back to the last HWPOD projection when the node is offline", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-partial-"));
|
|
const defaultRoot = join(root, "default");
|
|
const configPath = join(root, "sources.json");
|
|
let hwpodState = "ready";
|
|
try {
|
|
await mkdir(defaultRoot, { recursive: true });
|
|
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
|
await writeFile(configPath, JSON.stringify({
|
|
sources: [{
|
|
sourceId: "offline-hwpod-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
displayName: "Offline HWPOD MDTODO",
|
|
projectId: "project_hwpod_partial",
|
|
hwpodId: "offline-hwpod",
|
|
nodeId: "node-offline",
|
|
workspaceRootRef: "F:/Work/Offline",
|
|
mdtodoRootRef: "docs/MDTODO"
|
|
}]
|
|
}), "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: { HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: configPath },
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: defaultRoot,
|
|
sourceId: "default-mdtodo",
|
|
projectId: "project_test",
|
|
hwpodNodeOpsHandler: (plan) => {
|
|
if (hwpodState === "bug") throw new Error("programmer bug with internal detail");
|
|
if (hwpodState === "offline") {
|
|
const blocker = { code: "hwpod_node_offline", summary: "node-offline is not connected" };
|
|
return { ok: false, status: "blocked", blocker, results: [{ opId: "op_01", op: plan.intent, ok: false, status: "blocked", blocker }] };
|
|
}
|
|
const output = plan.intent === "workspace.ls"
|
|
? { entries: [{ name: "offline.md", type: "file" }] }
|
|
: { content: "# Offline\n\n## R1 Projected task [in_progress]\n\nProjected body. See [report](./reports/projected.md).\n" };
|
|
return { ok: true, status: "completed", results: [{ opId: "op_01", op: plan.intent, ok: true, status: "completed", output }] };
|
|
}
|
|
});
|
|
|
|
expect((await app.fetch(new Request("http://service/health/ready"))).status).toBe(200);
|
|
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=offline-hwpod-mdtodo");
|
|
const tasks = await json(app, `/v1/project-management/mdtodo/tasks?sourceId=offline-hwpod-mdtodo&fileRef=${encodeURIComponent(files.files[0].fileRef)}`);
|
|
hwpodState = "offline";
|
|
|
|
const response = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`));
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json();
|
|
expect(payload.status).toBe("partial");
|
|
expect(payload.task).toMatchObject({ taskId: "R1", title: "Projected task" });
|
|
expect(payload.task.bodyPreview).toContain("Projected body.");
|
|
expect(payload.detail).toBeNull();
|
|
expect(payload.file).toMatchObject({ fileRef: files.files[0].fileRef, taskCount: 1 });
|
|
expect(payload.detailAuthority).toMatchObject({
|
|
state: "unavailable",
|
|
authority: "project-management-projection",
|
|
sourceKind: "hwpod-workspace",
|
|
code: "hwpod_node_offline",
|
|
retryable: true,
|
|
valuesRedacted: true
|
|
});
|
|
expect(JSON.stringify(payload)).not.toContain("node-offline is not connected");
|
|
|
|
const projectedLink = tasks.tasks[0].links[0];
|
|
const reportResponse = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}&linkId=${encodeURIComponent(projectedLink.linkId)}`));
|
|
expect(reportResponse.status).toBe(503);
|
|
const reportPayload = await reportResponse.json();
|
|
expect(reportPayload).toMatchObject({ error: { code: "hwpod_node_offline", message: "HWPOD 源当前不可读;报告预览需等待 Node 恢复。", status: 503 } });
|
|
expect(JSON.stringify(reportPayload)).not.toContain("node-offline is not connected");
|
|
|
|
hwpodState = "bug";
|
|
const programmerError = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`));
|
|
expect(programmerError.status).toBe(500);
|
|
expect(await programmerError.json()).toMatchObject({ error: { code: "project_management_error", status: 500 } });
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("project management HWPOD source adapter authenticates node-ops requests from configured env", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-auth-"));
|
|
const defaultRoot = join(root, "default");
|
|
const seen = [];
|
|
try {
|
|
await mkdir(defaultRoot, { recursive: true });
|
|
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
|
const app = createProjectManagementApp({
|
|
env: {
|
|
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: "http://cloud-api.test/v1/hwpod-node-ops",
|
|
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: "hwl_live_project_management_test.redacted"
|
|
},
|
|
store: createProjectManagementStore({ kind: "memory" }),
|
|
sourceRoot: defaultRoot,
|
|
sourceId: "default-mdtodo",
|
|
projectId: "project_test",
|
|
fetchImpl: async (url, init) => {
|
|
const headers = new Headers(init.headers);
|
|
seen.push({
|
|
url: String(url),
|
|
authorization: headers.get("authorization"),
|
|
sourceServiceId: headers.get("x-source-service-id")
|
|
});
|
|
return new Response(JSON.stringify({
|
|
ok: true,
|
|
status: "completed",
|
|
results: [{ opId: "op_01", op: "workspace.ls", ok: true, status: "completed", output: { entries: [] } }]
|
|
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
});
|
|
|
|
const createResponse = await app.fetch(jsonRequest("/v1/project-management/mdtodo/sources", {
|
|
method: "POST",
|
|
body: {
|
|
sourceId: "hwpod-mdtodo",
|
|
sourceKind: "hwpod-workspace",
|
|
displayName: "D601 F103 MDTODO",
|
|
projectId: "project_test",
|
|
hwpodId: "d601-f103-v2",
|
|
nodeId: "node-d601-f103-v2",
|
|
workspaceRootRef: "F:/Work/ConStart",
|
|
mdtodoRootRef: "docs/MDTODO/"
|
|
}
|
|
}));
|
|
expect(createResponse.status).toBe(201);
|
|
|
|
const probe = await post(app, "/v1/project-management/mdtodo/sources/hwpod-mdtodo/probe", {});
|
|
expect(probe.probe.status).toBe("ok");
|
|
expect(seen[0]).toEqual({
|
|
url: "http://cloud-api.test/v1/hwpod-node-ops",
|
|
authorization: "Bearer hwl_live_project_management_test.redacted",
|
|
sourceServiceId: "hwlab-project-management"
|
|
});
|
|
await app.close();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function json(app, path) {
|
|
const response = await app.fetch(new Request(`http://service${path}`, { headers: { "x-hwlab-actor-id": "usr_test" } }));
|
|
expect(response.status).toBe(200);
|
|
return response.json();
|
|
}
|
|
|
|
async function post(app, path, body) {
|
|
const response = await app.fetch(jsonRequest(path, { method: "POST", body }));
|
|
expect(response.status).toBe(200);
|
|
return response.json();
|
|
}
|
|
|
|
function jsonRequest(path, options = {}) {
|
|
return new Request(`http://service${path}`, {
|
|
method: options.method ?? "GET",
|
|
headers: { "content-type": "application/json", "x-hwlab-actor-id": "usr_test" },
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
|
});
|
|
}
|