This commit is contained in:
parent
d587c91305
commit
ad85995108
2 changed files with 191 additions and 3 deletions
|
|
@ -172,11 +172,23 @@ export async function dispatchDirectPhase(
|
|||
|
||||
case "uat":
|
||||
case "run-uat": {
|
||||
const sid = state.activeSlice?.id;
|
||||
if (!sid) {
|
||||
ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning");
|
||||
// UAT targets the most recently completed slice, not the active (next
|
||||
// incomplete) slice. After slice completion, state.activeSlice advances
|
||||
// to the next incomplete slice, so we find the last done slice from the
|
||||
// roadmap instead (#1693).
|
||||
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
||||
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
||||
if (!roadmapContent) {
|
||||
ctx.ui.notify("Cannot dispatch run-uat: no roadmap found.", "warning");
|
||||
return;
|
||||
}
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
const completedSlices = roadmap.slices.filter(s => s.done);
|
||||
if (completedSlices.length === 0) {
|
||||
ctx.ui.notify("Cannot dispatch run-uat: no completed slices.", "warning");
|
||||
return;
|
||||
}
|
||||
const sid = completedSlices[completedSlices.length - 1].id;
|
||||
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
|
||||
if (!uatFile) {
|
||||
ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
// Regression test for #1693 — /gsd dispatch uat targets the last completed
|
||||
// slice from the roadmap instead of state.activeSlice (which has already
|
||||
// advanced to the next incomplete slice).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { dispatchDirectPhase } from "../auto-direct-dispatch.ts";
|
||||
import { invalidateStateCache } from "../state.ts";
|
||||
|
||||
function createFixture(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-dispatch-uat-"));
|
||||
|
||||
// Milestone M001 with two slices: S01 done, S02 incomplete
|
||||
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
||||
mkdirSync(milestoneDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(milestoneDir, "M001-CONTEXT.md"),
|
||||
"# M001: Test Milestone\n\nContext.\n",
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(milestoneDir, "M001-ROADMAP.md"),
|
||||
[
|
||||
"# M001: Test Milestone",
|
||||
"",
|
||||
"## Slices",
|
||||
"",
|
||||
"- [x] **S01: Completed slice** `risk:low` `depends:[]`",
|
||||
"- [ ] **S02: Active slice** `risk:low` `depends:[S01]`",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// S01 has a UAT file (this is the one dispatch should target)
|
||||
const s01Dir = join(milestoneDir, "slices", "S01");
|
||||
mkdirSync(s01Dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(s01Dir, "S01-UAT.md"),
|
||||
"# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n\n## Scenarios\n\n- Check output\n",
|
||||
);
|
||||
// S01 needs a PLAN with completed tasks so deriveState considers it done
|
||||
writeFileSync(
|
||||
join(s01Dir, "S01-PLAN.md"),
|
||||
"# S01 Plan\n\n## Tasks\n\n- [x] **T01: Task one** `effort:low`\n",
|
||||
);
|
||||
const t01Dir = join(s01Dir, "tasks", "T01");
|
||||
mkdirSync(t01Dir, { recursive: true });
|
||||
writeFileSync(join(t01Dir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.\n");
|
||||
|
||||
// S02 has a plan but incomplete tasks — this is where activeSlice points
|
||||
const s02Dir = join(milestoneDir, "slices", "S02");
|
||||
mkdirSync(s02Dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(s02Dir, "S02-PLAN.md"),
|
||||
"# S02 Plan\n\n## Tasks\n\n- [ ] **T01: Task one** `effort:low`\n",
|
||||
);
|
||||
const s02t01Dir = join(s02Dir, "tasks", "T01");
|
||||
mkdirSync(s02t01Dir, { recursive: true });
|
||||
writeFileSync(join(s02t01Dir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.\n");
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
test("dispatch uat targets last completed slice, not activeSlice (#1693)", async () => {
|
||||
const base = createFixture();
|
||||
invalidateStateCache();
|
||||
|
||||
const notifications: { message: string; level: string }[] = [];
|
||||
let sentPrompt: string | undefined;
|
||||
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: (message: string, level: string) => {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
},
|
||||
newSession: async () => ({ cancelled: false }),
|
||||
} as any;
|
||||
|
||||
const pi = {
|
||||
sendMessage: (msg: { content: string }, _opts: unknown) => {
|
||||
sentPrompt = msg.content;
|
||||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
|
||||
// Should have dispatched (sendMessage called)
|
||||
assert.ok(sentPrompt, "sendMessage should have been called with a prompt");
|
||||
|
||||
// The dispatch notification should reference M001/S01 (completed), not M001/S02 (active)
|
||||
const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching"));
|
||||
assert.ok(dispatchNotification, "dispatch notification should be present");
|
||||
assert.match(
|
||||
dispatchNotification.message,
|
||||
/M001\/S01/,
|
||||
"dispatch should target completed slice S01, not active slice S02",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
dispatchNotification.message,
|
||||
/M001\/S02/,
|
||||
"dispatch should NOT target active (next incomplete) slice S02",
|
||||
);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dispatch uat warns when no completed slices exist", async () => {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-dispatch-uat-none-"));
|
||||
invalidateStateCache();
|
||||
|
||||
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
||||
mkdirSync(milestoneDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(milestoneDir, "M001-CONTEXT.md"),
|
||||
"# M001: Test Milestone\n\nContext.\n",
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(milestoneDir, "M001-ROADMAP.md"),
|
||||
[
|
||||
"# M001: Test",
|
||||
"",
|
||||
"## Slices",
|
||||
"",
|
||||
"- [ ] **S01: First** `risk:low` `depends:[]`",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// S01 needs a plan so state derivation doesn't stop at planning phase
|
||||
const s01Dir = join(milestoneDir, "slices", "S01");
|
||||
mkdirSync(s01Dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(s01Dir, "S01-PLAN.md"),
|
||||
"# S01 Plan\n\n## Tasks\n\n- [ ] **T01: Task** `effort:low`\n",
|
||||
);
|
||||
const t01Dir = join(s01Dir, "tasks", "T01");
|
||||
mkdirSync(t01Dir, { recursive: true });
|
||||
writeFileSync(join(t01Dir, "T01-PLAN.md"), "# T01 Plan\n");
|
||||
|
||||
const notifications: { message: string; level: string }[] = [];
|
||||
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: (message: string, level: string) => {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
},
|
||||
newSession: async () => ({ cancelled: false }),
|
||||
} as any;
|
||||
|
||||
const pi = {
|
||||
sendMessage: () => {
|
||||
assert.fail("sendMessage should not be called when no completed slices");
|
||||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await dispatchDirectPhase(ctx, pi, "uat", base);
|
||||
|
||||
const warning = notifications.find(n => n.level === "warning");
|
||||
assert.ok(warning, "should show a warning notification");
|
||||
assert.match(warning.message, /no completed slices/, "warning should mention no completed slices");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue