From d9e760224c2f7e9903f13dedd539c59e208fb1c5 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:48:01 +0800 Subject: [PATCH] test(dashboard): accept the team plan confirmation path in a browser The unit smoke proves the transport accepts `team.plan` and that the reducer renders the lanes; nothing proved the click path an owner actually takes. This adds the `team-plan` scenario to the personal workspace browser acceptance: - a validated multi-lane preview becomes a proposal row that names the action kind; - the card shows the Goal, the objective, a ready lane's Agent, first bounded Todo, priority, action kind and acceptance signal, the declared staffing gap with its reason and the work it did not staff, the quota envelope and the stop condition; - the card never claims a lane already exists before confirmation; - confirming sends exactly one apply for the confirmed proposal and performs exactly one durable write, and the drawer then reports the applied state; - no client-side exception is raised, and no LoopX API call fails while confirming. A development-server resource status is not treated as a client failure, since it is a harness path question rather than product behavior. Verified locally: the scenario alone passes, and the full personal workspace browser acceptance passes with the scenario registered (`navigation-sorting,chat-recovery,typed-actions,team-plan,execution-chip`). Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/personal-workspace-browser-smoke.mjs | 3 +- .../personal-workspace-browser/team-plan.mjs | 189 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 examples/personal-workspace-browser/team-plan.mjs diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index f0e86cb67d..7c665a9681 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -22,9 +22,10 @@ import { startServer, } from "./personal-workspace-browser/fixture.mjs"; import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; +import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; -const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, executionChipScenario]; +const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/team-plan.mjs b/examples/personal-workspace-browser/team-plan.mjs new file mode 100644 index 0000000000..35c324bdf0 --- /dev/null +++ b/examples/personal-workspace-browser/team-plan.mjs @@ -0,0 +1,189 @@ +// Browser acceptance for confirming a validated steward team plan. +// +// The unit smoke proves the transport accepts the kind and that the reducer +// renders the lanes. This scenario proves the click path an owner actually +// takes: a validated multi-lane preview becomes a card, the card shows every +// lane and its gap, and confirming it sends exactly one apply. + +import { resolve } from "node:path"; + +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +const GOAL_ID = "product-release"; +const PROPOSAL_ID = "proposal-team-plan-fixture"; +const READY_TODO = "Implement the bounded intake"; +const GAP_TODO = "Independently review the intake"; + +function teamPlanProposal() { + return { + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: PROPOSAL_ID, + action_kind: "team.plan", + summary: "为 product-release 配出 2 条 lane 的团队", + normalized_parameters: { + goal_id: GOAL_ID, + plan: { + schema_version: "steward_team_plan_preview_v0", + kind: "steward_team_plan_preview", + goal_id: GOAL_ID, + objective: "Ship the bounded intake", + lanes: [ + { + lane_id: "lane_intake", + agent_id: "agent-backend", + acceptance: "the bounded Todo is created through the canonical owner", + staffing: "ready", + first_todo: { + text: READY_TODO, + priority: "P1", + task_class: "advancement_task", + action_kind: "implement", + }, + }, + { + lane_id: "lane_review", + agent_id: "agent-reviewer", + acceptance: "the review receipt is recorded", + staffing: "gap", + gap_reason_code: "agent_not_registered", + declined_first_todo: { + text: GAP_TODO, + priority: "P1", + task_class: "advancement_task", + action_kind: "validate", + }, + }, + ], + gaps: [{ lane_id: "lane_review", reason_code: "agent_not_registered" }], + quota_envelope: { slots: 4, window: "1d" }, + stop_condition: "every lane reports a typed outcome or a stated gap", + applies: false, + }, + requested_by: "owner", + }, + context: { kind: "goal", goal_id: GOAL_ID }, + expected_state_fingerprint: "fixture-team-plan-r1", + permission_classification: "durable_write", + validation_evidence: ["every ready lane names an Agent this Goal registers"], + available_transitions: ["apply", "cancel"], + status: "preview_ready", + receipt: null, + stale: null, + created_at: "2026-09-16T01:00:00Z", + updated_at: "2026-09-16T01:00:01Z", + }; +} + +export const teamPlanScenario = { + id: "team-plan", + async run({ browser, collectCoverage, url }) { + const failures = []; + const notes = []; + const check = (condition, message) => { + if (condition) notes.push(message); + else failures.push(message); + }; + const context = await openWorkspacePage(browser, url, { + apiOptions: { initialActionProposals: [teamPlanProposal()] }, + collectCoverage, + }); + try { + const { api, page } = context; + // Only the product's own API calls are asserted here: the development + // server may refuse to serve an asset it considers outside its root, + // which is a harness path question rather than a client failure. + const failedResponses = []; + page.on("response", (response) => { + if (response.status() >= 400 && response.url().includes("/api/")) { + failedResponses.push(`${response.status()} ${response.url()}`); + } + }); + await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.locator(".personal-goal-tabs button", { hasText: "Chat" }).click(); + + const row = page.locator(".personal-proposal-row", { hasText: "配出 2 条 lane" }); + try { + await row.waitFor({ state: "visible", timeout: 15_000 }); + } catch (error) { + throw new Error( + `${error.message}; rows=${JSON.stringify(await page.locator(".personal-proposal-row").allInnerTexts())};` + + ` body=${(await page.locator("body").innerText()).slice(0, 1500)}`, + ); + } + check((await row.innerText()).includes("team.plan"), "the proposal row names the team.plan action kind"); + + await row.click(); + const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); + await drawer.getByText("配额包络", { exact: true }).waitFor({ state: "visible", timeout: 15_000 }); + const previewText = await drawer.innerText(); + check(previewText.includes("agent-backend"), "a ready lane names the Agent that runs it"); + check( + previewText.includes(`P1 · implement · ${READY_TODO}`), + "a ready lane shows its first bounded Todo with its priority and action kind", + ); + check( + previewText.includes("验收: the bounded Todo is created through the canonical owner"), + "a ready lane shows its acceptance signal", + ); + check( + previewText.includes("agent-reviewer") + && previewText.includes("未配齐") + && previewText.includes("agent_not_registered") + && previewText.includes(GAP_TODO), + "a gap lane says it is unstaffed, names the reason, and keeps the work it did not staff", + ); + check( + previewText.includes("配额包络") && previewText.includes("slots: 4") && previewText.includes("停止条件"), + "the quota envelope and the stop condition render", + ); + check(previewText.includes("确认后会通过既有 owner"), "the card states what confirming does"); + check( + !/(已创建|已经创建|lanes created|已组建)/u.test(previewText), + "the card never claims a lane already exists before confirmation", + ); + await page.screenshot({ + path: resolve(outputDir, "team-plan-preview.png"), + fullPage: false, + animations: "disabled", + }); + + const confirm = drawer.getByRole("button", { name: "确认并组建各 lane", exact: true }); + check(await confirm.count() === 1, "exactly one confirmation control is offered"); + check(await confirm.isEnabled(), "the validated preview is confirmable"); + await confirm.click(); + + for (let attempt = 0; attempt < 60 && api.actionApplies.length === 0; attempt += 1) { + await page.waitForTimeout(50); + } + check( + api.actionApplies.filter((proposalId) => proposalId === PROPOSAL_ID).length === 1, + "confirming sends exactly one apply for the confirmed proposal", + ); + check(api.durableWriteCount === 1, "the confirmed apply performed exactly one durable write"); + await drawer.getByText("已应用,LoopX 状态将刷新。", { exact: true }) + .waitFor({ state: "visible", timeout: 15_000 }); + await page.screenshot({ + path: resolve(outputDir, "team-plan-applied.png"), + fullPage: false, + animations: "disabled", + }); + // The harness collects both uncaught page errors and console errors; a + // dev-server resource status is not a client-side exception, so only the + // former is a failure here. + const scriptErrors = context.errors.filter((message) => !message.startsWith("Failed to load resource")); + check( + scriptErrors.length === 0, + `no client-side exception was raised (${scriptErrors.join(" | ")})`, + ); + check( + failedResponses.length === 0, + `no LoopX API call failed while confirming the plan (${failedResponses.join(" | ")})`, + ); + } finally { + await context.close(); + } + if (failures.length) throw new Error(failures.join(" | ")); + return { coverageEntries: context.coverageEntries, note: notes.join(" ") }; + }, +};