@gr8ful/spf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +321 -0
- package/assets/defaults/spf.config.yaml +141 -0
- package/assets/prompts/builder/system.md +13 -0
- package/assets/prompts/builder/user.md +34 -0
- package/assets/prompts/documenter/system.md +17 -0
- package/assets/prompts/documenter/user.md +48 -0
- package/assets/prompts/planner/system.md +21 -0
- package/assets/prompts/planner/user.md +45 -0
- package/assets/prompts/reviewer/system.md +16 -0
- package/assets/prompts/reviewer/user.md +44 -0
- package/assets/prompts/scout/system.md +20 -0
- package/assets/prompts/scout/user.md +34 -0
- package/assets/skill/SKILL.md +80 -0
- package/assets/skill/cookbooks/authoring_chains.md +193 -0
- package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
- package/assets/skill/cookbooks/roster.md +197 -0
- package/assets/skill/cookbooks/run_adw.md +92 -0
- package/assets/skill/cookbooks/spf_overview.md +111 -0
- package/assets/skill/references/config.md +188 -0
- package/assets/skill/references/handoff.md +162 -0
- package/assets/skill/references/observability.md +184 -0
- package/dist/chains/adw_build.d.ts +12 -0
- package/dist/chains/adw_build.js +27 -0
- package/dist/chains/adw_build_review.d.ts +21 -0
- package/dist/chains/adw_build_review.js +55 -0
- package/dist/chains/adw_build_test.d.ts +21 -0
- package/dist/chains/adw_build_test.js +67 -0
- package/dist/chains/adw_document.d.ts +23 -0
- package/dist/chains/adw_document.js +59 -0
- package/dist/chains/adw_plan.d.ts +12 -0
- package/dist/chains/adw_plan.js +27 -0
- package/dist/chains/adw_plan_build.d.ts +12 -0
- package/dist/chains/adw_plan_build.js +30 -0
- package/dist/chains/adw_plan_build_test.d.ts +16 -0
- package/dist/chains/adw_plan_build_test.js +65 -0
- package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
- package/dist/chains/adw_plan_build_test_quality.js +66 -0
- package/dist/chains/adw_prompt.d.ts +12 -0
- package/dist/chains/adw_prompt.js +25 -0
- package/dist/chains/adw_quality.d.ts +12 -0
- package/dist/chains/adw_quality.js +32 -0
- package/dist/chains/adw_scout.d.ts +12 -0
- package/dist/chains/adw_scout.js +27 -0
- package/dist/chains/adw_simple_sdlc.d.ts +43 -0
- package/dist/chains/adw_simple_sdlc.js +147 -0
- package/dist/chains/context.d.ts +19 -0
- package/dist/chains/context.js +1 -0
- package/dist/chains/index.d.ts +19 -0
- package/dist/chains/index.js +116 -0
- package/dist/cli/bin.d.ts +15 -0
- package/dist/cli/bin.js +24 -0
- package/dist/cli/commands/abort.d.ts +1 -0
- package/dist/cli/commands/abort.js +42 -0
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.js +165 -0
- package/dist/cli/commands/eject.d.ts +1 -0
- package/dist/cli/commands/eject.js +55 -0
- package/dist/cli/commands/events.d.ts +1 -0
- package/dist/cli/commands/events.js +49 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +62 -0
- package/dist/cli/commands/install-skill.d.ts +1 -0
- package/dist/cli/commands/install-skill.js +122 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/migrate.d.ts +1 -0
- package/dist/cli/commands/migrate.js +167 -0
- package/dist/cli/commands/phases.d.ts +1 -0
- package/dist/cli/commands/phases.js +25 -0
- package/dist/cli/commands/run.d.ts +3 -0
- package/dist/cli/commands/run.js +27 -0
- package/dist/cli/commands/sessions.d.ts +1 -0
- package/dist/cli/commands/sessions.js +20 -0
- package/dist/cli/commands/trace.d.ts +8 -0
- package/dist/cli/commands/trace.js +10 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +35 -0
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +210 -0
- package/dist/cli/gitignore.d.ts +1 -0
- package/dist/cli/gitignore.js +14 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/core/agent_cc.d.ts +75 -0
- package/dist/core/agent_cc.js +322 -0
- package/dist/core/agent_flue.d.ts +66 -0
- package/dist/core/agent_flue.js +321 -0
- package/dist/core/agents.d.ts +64 -0
- package/dist/core/agents.js +456 -0
- package/dist/core/changes.d.ts +35 -0
- package/dist/core/changes.js +98 -0
- package/dist/core/console.d.ts +36 -0
- package/dist/core/console.js +156 -0
- package/dist/core/data_types.d.ts +562 -0
- package/dist/core/data_types.js +382 -0
- package/dist/core/gates.d.ts +33 -0
- package/dist/core/gates.js +144 -0
- package/dist/core/git_helper.d.ts +59 -0
- package/dist/core/git_helper.js +115 -0
- package/dist/core/issues/github_provider.d.ts +52 -0
- package/dist/core/issues/github_provider.js +211 -0
- package/dist/core/issues/provider.d.ts +90 -0
- package/dist/core/issues/provider.js +14 -0
- package/dist/core/paths.d.ts +78 -0
- package/dist/core/paths.js +108 -0
- package/dist/core/permissions.d.ts +78 -0
- package/dist/core/permissions.js +187 -0
- package/dist/core/prompts.d.ts +4 -0
- package/dist/core/prompts.js +17 -0
- package/dist/core/quality.d.ts +65 -0
- package/dist/core/quality.js +194 -0
- package/dist/core/runner.d.ts +72 -0
- package/dist/core/runner.js +168 -0
- package/dist/core/session.d.ts +16 -0
- package/dist/core/session.js +60 -0
- package/dist/core/sqlite.d.ts +55 -0
- package/dist/core/sqlite.js +106 -0
- package/dist/core/tracer.d.ts +56 -0
- package/dist/core/tracer.js +246 -0
- package/dist/core/utils.d.ts +40 -0
- package/dist/core/utils.js +108 -0
- package/dist/core/watch.d.ts +44 -0
- package/dist/core/watch.js +194 -0
- package/dist/test/agent_cc.test.d.ts +1 -0
- package/dist/test/agent_cc.test.js +95 -0
- package/dist/test/agent_flue.test.d.ts +1 -0
- package/dist/test/agent_flue.test.js +83 -0
- package/dist/test/data_types.test.d.ts +10 -0
- package/dist/test/data_types.test.js +49 -0
- package/dist/test/ui_server.test.d.ts +1 -0
- package/dist/test/ui_server.test.js +119 -0
- package/dist/test/watch.test.d.ts +1 -0
- package/dist/test/watch.test.js +227 -0
- package/dist/ui/server/app.d.ts +3 -0
- package/dist/ui/server/app.js +98 -0
- package/dist/ui/server/db.d.ts +82 -0
- package/dist/ui/server/db.js +333 -0
- package/dist/ui/server/serve.d.ts +13 -0
- package/dist/ui/server/serve.js +80 -0
- package/dist/ui/server/static.d.ts +5 -0
- package/dist/ui/server/static.js +55 -0
- package/dist/ui/shared/types.d.ts +263 -0
- package/dist/ui/shared/types.js +8 -0
- package/package.json +52 -0
- package/web/assets/index-C7nF068F.css +1 -0
- package/web/assets/index-mzSArcnQ.js +11 -0
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
- package/web/index.html +14 -0
- package/web/logo.svg +6 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { isKnownToolName, CcToolCallTracker } from "../core/agent_cc.js";
|
|
4
|
+
test("isKnownToolName: SPF's lowercase vocabulary maps onto Claude Code's own tool names, ls is known-but-dropped, typos are unknown", () => {
|
|
5
|
+
for (const name of ["read", "write", "edit", "bash", "grep", "glob"]) {
|
|
6
|
+
assert.equal(isKnownToolName(name), true, name);
|
|
7
|
+
}
|
|
8
|
+
assert.equal(isKnownToolName("find"), true, "find aliases to glob");
|
|
9
|
+
assert.equal(isKnownToolName("ls"), true, "ls is a known name, just dropped");
|
|
10
|
+
assert.equal(isKnownToolName("harness_engineering_only"), false);
|
|
11
|
+
assert.equal(isKnownToolName("bogus"), false);
|
|
12
|
+
});
|
|
13
|
+
// Every message shape below is a real captured line from `claude -p
|
|
14
|
+
// --output-format stream-json --verbose` (v2.1.237), not a guess — see
|
|
15
|
+
// agent_cc.ts's module doc comment for the exact commands run.
|
|
16
|
+
test("CcToolCallTracker: folds a real tool_use + tool_result pair into one record", () => {
|
|
17
|
+
const tracker = new CcToolCallTracker();
|
|
18
|
+
assert.equal(tracker.observe({
|
|
19
|
+
type: "assistant",
|
|
20
|
+
message: {
|
|
21
|
+
content: [{ type: "tool_use", id: "toolu_01ABC", name: "Read", input: { file_path: "/private/tmp/cc-probe/test.txt" } }],
|
|
22
|
+
},
|
|
23
|
+
timestamp: "2026-08-20T11:40:00.000Z",
|
|
24
|
+
}), null, "the assistant's tool_use alone produces no record");
|
|
25
|
+
const record = tracker.observe({
|
|
26
|
+
type: "user",
|
|
27
|
+
message: {
|
|
28
|
+
content: [{ type: "tool_result", tool_use_id: "toolu_01ABC", content: "1\thello world\n2\t" }],
|
|
29
|
+
},
|
|
30
|
+
timestamp: "2026-08-20T11:40:00.500Z",
|
|
31
|
+
});
|
|
32
|
+
assert.ok(record);
|
|
33
|
+
assert.equal(record.tool, "Read");
|
|
34
|
+
assert.equal(record.tool_call_id, "toolu_01ABC");
|
|
35
|
+
assert.deepEqual(record.args, { file_path: "/private/tmp/cc-probe/test.txt" });
|
|
36
|
+
assert.equal(record.ok, true);
|
|
37
|
+
assert.equal(record.label, "Read: /private/tmp/cc-probe/test.txt");
|
|
38
|
+
assert.equal(record.result_snippet, "1\thello world\n2\t");
|
|
39
|
+
assert.equal(record.duration_ms, 500);
|
|
40
|
+
});
|
|
41
|
+
test("CcToolCallTracker: a real StructuredOutput call folds like any other tool call, deliberately unfiltered", () => {
|
|
42
|
+
const tracker = new CcToolCallTracker();
|
|
43
|
+
tracker.observe({
|
|
44
|
+
type: "assistant",
|
|
45
|
+
message: {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "tool_use",
|
|
49
|
+
id: "toolu_01MPFgPqyPeEzWfqRE25JLzF",
|
|
50
|
+
name: "StructuredOutput",
|
|
51
|
+
input: { status: "success", summary: "hello from claude code" },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const record = tracker.observe({
|
|
57
|
+
type: "user",
|
|
58
|
+
message: {
|
|
59
|
+
content: [{ type: "tool_result", tool_use_id: "toolu_01MPFgPqyPeEzWfqRE25JLzF", content: "Structured output provided successfully" }],
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
assert.ok(record);
|
|
63
|
+
assert.equal(record.tool, "StructuredOutput");
|
|
64
|
+
assert.equal(record.ok, true);
|
|
65
|
+
assert.equal(record.result_snippet, "Structured output provided successfully");
|
|
66
|
+
});
|
|
67
|
+
test("CcToolCallTracker: is_error:true on the tool_result folds to ok:false", () => {
|
|
68
|
+
const tracker = new CcToolCallTracker();
|
|
69
|
+
tracker.observe({
|
|
70
|
+
type: "assistant",
|
|
71
|
+
message: { content: [{ type: "tool_use", id: "toolu_err", name: "Bash", input: { command: "false" } }] },
|
|
72
|
+
});
|
|
73
|
+
const record = tracker.observe({
|
|
74
|
+
type: "user",
|
|
75
|
+
message: { content: [{ type: "tool_result", tool_use_id: "toolu_err", content: "command failed", is_error: true }] },
|
|
76
|
+
});
|
|
77
|
+
assert.ok(record);
|
|
78
|
+
assert.equal(record.ok, false);
|
|
79
|
+
});
|
|
80
|
+
test("CcToolCallTracker: non-tool message types (system, thinking-only assistant, result) produce no record", () => {
|
|
81
|
+
const tracker = new CcToolCallTracker();
|
|
82
|
+
assert.equal(tracker.observe({ type: "system", subtype: "init", cwd: "/tmp", session_id: "s1", tools: [] }), null);
|
|
83
|
+
assert.equal(tracker.observe({ type: "assistant", message: { content: [{ type: "thinking", thinking: "..." }] } }), null);
|
|
84
|
+
assert.equal(tracker.observe({ type: "result", subtype: "success", session_id: "s1" }), null);
|
|
85
|
+
});
|
|
86
|
+
test("CcToolCallTracker: a tool_result with no matching tool_use still produces a record (defensive)", () => {
|
|
87
|
+
const tracker = new CcToolCallTracker();
|
|
88
|
+
const record = tracker.observe({
|
|
89
|
+
type: "user",
|
|
90
|
+
message: { content: [{ type: "tool_result", tool_use_id: "orphan", content: "ok" }] },
|
|
91
|
+
});
|
|
92
|
+
assert.ok(record);
|
|
93
|
+
assert.equal(record.tool, "tool");
|
|
94
|
+
assert.deepEqual(record.args, {});
|
|
95
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { isKnownToolName, resolveModel, ToolCallTracker } from "../core/agent_flue.js";
|
|
4
|
+
test("resolveModel: accepts provider/model-id, rejects anything else", () => {
|
|
5
|
+
assert.deepEqual(resolveModel("openrouter/google/gemini-3.6-flash"), ["openrouter", "google/gemini-3.6-flash"]);
|
|
6
|
+
assert.deepEqual(resolveModel("openai/gpt-5.6-terra"), ["openai", "gpt-5.6-terra"]);
|
|
7
|
+
assert.throws(() => resolveModel("gpt-5.6-terra"), /must be written as provider\/model-id/);
|
|
8
|
+
assert.throws(() => resolveModel("/gpt-5.6-terra"), /must be written as provider\/model-id/);
|
|
9
|
+
assert.throws(() => resolveModel("openai/"), /must be written as provider\/model-id/);
|
|
10
|
+
});
|
|
11
|
+
test("isKnownToolName: pi vocabulary maps onto Flue's six builtins, ls is known-but-dropped, typos are unknown", () => {
|
|
12
|
+
for (const name of ["read", "write", "edit", "bash", "grep", "glob"]) {
|
|
13
|
+
assert.equal(isKnownToolName(name), true, name);
|
|
14
|
+
}
|
|
15
|
+
assert.equal(isKnownToolName("find"), true, "find aliases to glob");
|
|
16
|
+
assert.equal(isKnownToolName("ls"), true, "ls is a known name, just dropped");
|
|
17
|
+
assert.equal(isKnownToolName("subagent_create"), false, "harness_engineering-only names are unknown here");
|
|
18
|
+
assert.equal(isKnownToolName("bogus"), false);
|
|
19
|
+
});
|
|
20
|
+
test("ToolCallTracker: folds tool-input + tool-output into one record", () => {
|
|
21
|
+
const tracker = new ToolCallTracker();
|
|
22
|
+
assert.equal(tracker.observe({
|
|
23
|
+
type: "tool-input",
|
|
24
|
+
conversationId: "c1",
|
|
25
|
+
messageId: "m1",
|
|
26
|
+
toolCallId: "call1",
|
|
27
|
+
toolName: "bash",
|
|
28
|
+
input: { command: "ls -la src" },
|
|
29
|
+
position: { batch: 0, index: 0 },
|
|
30
|
+
}), null, "tool-input alone produces no record");
|
|
31
|
+
const record = tracker.observe({
|
|
32
|
+
type: "tool-output",
|
|
33
|
+
conversationId: "c1",
|
|
34
|
+
toolCallId: "call1",
|
|
35
|
+
output: "src/index.ts\nsrc/core",
|
|
36
|
+
durationMs: 12,
|
|
37
|
+
position: { batch: 0, index: 1 },
|
|
38
|
+
});
|
|
39
|
+
assert.ok(record);
|
|
40
|
+
assert.equal(record.tool, "bash");
|
|
41
|
+
assert.equal(record.tool_call_id, "call1");
|
|
42
|
+
assert.deepEqual(record.args, { command: "ls -la src" });
|
|
43
|
+
assert.equal(record.ok, true);
|
|
44
|
+
assert.equal(record.label, "bash: ls -la src");
|
|
45
|
+
assert.equal(record.result_snippet, "src/index.ts\nsrc/core");
|
|
46
|
+
assert.equal(record.duration_ms, 12);
|
|
47
|
+
});
|
|
48
|
+
test("ToolCallTracker: tool-output-error folds to ok:false with the error text as the snippet", () => {
|
|
49
|
+
const tracker = new ToolCallTracker();
|
|
50
|
+
tracker.observe({
|
|
51
|
+
type: "tool-input",
|
|
52
|
+
conversationId: "c1",
|
|
53
|
+
messageId: "m1",
|
|
54
|
+
toolCallId: "call2",
|
|
55
|
+
toolName: "read",
|
|
56
|
+
input: { path: "missing.txt" },
|
|
57
|
+
position: { batch: 0, index: 0 },
|
|
58
|
+
});
|
|
59
|
+
const record = tracker.observe({
|
|
60
|
+
type: "tool-output-error",
|
|
61
|
+
conversationId: "c1",
|
|
62
|
+
toolCallId: "call2",
|
|
63
|
+
errorText: "ENOENT: no such file",
|
|
64
|
+
position: { batch: 0, index: 1 },
|
|
65
|
+
});
|
|
66
|
+
assert.ok(record);
|
|
67
|
+
assert.equal(record.ok, false);
|
|
68
|
+
assert.equal(record.result_snippet, "ENOENT: no such file");
|
|
69
|
+
assert.equal(record.label, "read: missing.txt");
|
|
70
|
+
});
|
|
71
|
+
test("ToolCallTracker: a tool-output with no matching tool-input still produces a record (defensive)", () => {
|
|
72
|
+
const tracker = new ToolCallTracker();
|
|
73
|
+
const record = tracker.observe({
|
|
74
|
+
type: "tool-output",
|
|
75
|
+
conversationId: "c1",
|
|
76
|
+
toolCallId: "orphan",
|
|
77
|
+
output: "ok",
|
|
78
|
+
position: { batch: 0, index: 0 },
|
|
79
|
+
});
|
|
80
|
+
assert.ok(record);
|
|
81
|
+
assert.equal(record.tool, "tool");
|
|
82
|
+
assert.deepEqual(record.args, {});
|
|
83
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the zod -> Valibot conversion. Not exhaustive schema
|
|
3
|
+
* tests — these guard the three specific things that conversion could get
|
|
4
|
+
* wrong: the three-state writes/tools semantics that hard rule 9 depends on,
|
|
5
|
+
* default-value reference freshness (verified NOT to be a live bug in
|
|
6
|
+
* valibot 1.4.2 — see the comment below — kept as a tripwire against a
|
|
7
|
+
* future version regressing it), and toJsonSchema staying convertible now
|
|
8
|
+
* that it backs the sf_report tool wiring landing in the Flue phase.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the zod -> Valibot conversion. Not exhaustive schema
|
|
3
|
+
* tests — these guard the three specific things that conversion could get
|
|
4
|
+
* wrong: the three-state writes/tools semantics that hard rule 9 depends on,
|
|
5
|
+
* default-value reference freshness (verified NOT to be a live bug in
|
|
6
|
+
* valibot 1.4.2 — see the comment below — kept as a tripwire against a
|
|
7
|
+
* future version regressing it), and toJsonSchema staying convertible now
|
|
8
|
+
* that it backs the sf_report tool wiring landing in the Flue phase.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
14
|
+
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
15
|
+
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
16
|
+
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
17
|
+
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
18
|
+
assert.equal(unrestricted.writes, undefined, "key absent -> undefined (unrestricted)");
|
|
19
|
+
const explicitNull = v.parse(AgentConfigSchema, { ...base, writes: null });
|
|
20
|
+
assert.equal(explicitNull.writes, null, "explicit null -> null (also unrestricted)");
|
|
21
|
+
const readOnly = v.parse(AgentConfigSchema, { ...base, writes: [] });
|
|
22
|
+
assert.deepEqual(readOnly.writes, [], "explicit [] -> read-only, must stay an empty array, not undefined/null");
|
|
23
|
+
const allowlisted = v.parse(AgentConfigSchema, { ...base, writes: ["specs/"] });
|
|
24
|
+
assert.deepEqual(allowlisted.writes, ["specs/"]);
|
|
25
|
+
assert.throws(() => v.parse(AgentConfigSchema, { ...base, writes: "specs/" }), "a bare string must not be silently accepted as an allowlist");
|
|
26
|
+
});
|
|
27
|
+
test("array defaults never alias across parses (tripwire, not a fix — see module comment)", () => {
|
|
28
|
+
const first = v.parse(BuildOutput.schema, { status: "success" });
|
|
29
|
+
const second = v.parse(BuildOutput.schema, { status: "success" });
|
|
30
|
+
first.artifacts.push("should-not-leak");
|
|
31
|
+
first.changed_files.push("should-not-leak");
|
|
32
|
+
assert.deepEqual(second.artifacts, [], "a later parse must not see an earlier parse's push");
|
|
33
|
+
assert.deepEqual(second.changed_files, []);
|
|
34
|
+
assert.notEqual(first.artifacts, second.artifacts, "distinct array references");
|
|
35
|
+
});
|
|
36
|
+
test("PhaseParamsSchema rejects a blank description and an echo of the name, with the exact messages", () => {
|
|
37
|
+
assert.throws(() => makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: " " }), /description is required/);
|
|
38
|
+
assert.throws(() => makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Commit Plan" }), /only restates the phase name/);
|
|
39
|
+
const ok = makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: " Commit the plan file to the repo " });
|
|
40
|
+
assert.equal(ok.description, "Commit the plan file to the repo", "whitespace is collapsed, not just trimmed");
|
|
41
|
+
});
|
|
42
|
+
test("every envelope type still converts to JSON Schema (the sf_report tool wiring depends on this)", () => {
|
|
43
|
+
for (const envelope of [GenericOutput, PlanOutput, BuildOutput, ScoutOutput, ReviewOutput, DocumentOutput, ChangesOutput, VerifyOutput]) {
|
|
44
|
+
assert.doesNotThrow(() => toJsonSchema(envelope.schema), `${envelope.name} must stay convertible — no rawTransform allowed on an envelope schema`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
|
|
48
|
+
assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-parity regression test for the Bun.serve -> Hono port. Builds a real
|
|
3
|
+
* spf.db (via the tracer, not a fixture file) and drives runUi() with real
|
|
4
|
+
* HTTP requests — the same shape as the manual verification this port was
|
|
5
|
+
* checked against, kept as a regression guard rather than a one-off.
|
|
6
|
+
*/
|
|
7
|
+
import { test, before, after } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { Tracer } from "../core/tracer.js";
|
|
14
|
+
import { makeEventRecord } from "../core/data_types.js";
|
|
15
|
+
import { runUi } from "../ui/server/serve.js";
|
|
16
|
+
let dir;
|
|
17
|
+
let handle;
|
|
18
|
+
const ADW_ID = "t1";
|
|
19
|
+
before(async () => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "spf-ui-test-"));
|
|
21
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
22
|
+
// -c user.*, not a global git config: this must pass in a fresh CI runner
|
|
23
|
+
// with no git identity configured at all, not just on a dev machine that
|
|
24
|
+
// already has one.
|
|
25
|
+
execFileSync("git", ["-c", "user.email=test@example.com", "-c", "user.name=spf tests", "commit", "-q", "--allow-empty", "-m", "init"], { cwd: dir });
|
|
26
|
+
const dbPath = join(dir, "spf.db");
|
|
27
|
+
const tracer = new Tracer(dbPath, join(dir, "sessions", ADW_ID, "events.jsonl"));
|
|
28
|
+
tracer.sessionStart(ADW_ID, "tester", "adw_quality");
|
|
29
|
+
const phase = {
|
|
30
|
+
phase_id: `${ADW_ID}_01_request`,
|
|
31
|
+
adw_id: ADW_ID,
|
|
32
|
+
seq: 1,
|
|
33
|
+
params: { name: "request", kind: "engineer", owner: "tester", description: "seed phase", retries: 0 },
|
|
34
|
+
status: "success",
|
|
35
|
+
attempt: 0,
|
|
36
|
+
error: null,
|
|
37
|
+
started_at: null,
|
|
38
|
+
ended_at: null,
|
|
39
|
+
};
|
|
40
|
+
tracer.phaseUpsert(phase);
|
|
41
|
+
tracer.event(makeEventRecord({ adw_id: ADW_ID, phase_id: phase.phase_id, type: "log", name: "request", payload: { input: "seed" } }));
|
|
42
|
+
tracer.sessionFinish(ADW_ID, true);
|
|
43
|
+
// dist/test/ui_server.test.js -> dist -> package root -> web
|
|
44
|
+
const webDir = join(import.meta.dirname, "..", "..", "web");
|
|
45
|
+
handle = await runUi({ dbPath, webDir, port: 0, open: false });
|
|
46
|
+
});
|
|
47
|
+
after(async () => {
|
|
48
|
+
await handle.close();
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
async function get(path, init) {
|
|
52
|
+
const res = await fetch(handle.url + path, init);
|
|
53
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
54
|
+
const body = contentType.includes("json") ? await res.json() : await res.text();
|
|
55
|
+
return { status: res.status, body, contentType };
|
|
56
|
+
}
|
|
57
|
+
test("GET /api/health reports the real db path and session count", async () => {
|
|
58
|
+
const { status, body } = await get("/api/health");
|
|
59
|
+
assert.equal(status, 200);
|
|
60
|
+
assert.equal(body.ok, true);
|
|
61
|
+
assert.equal(body.journal_mode, "wal");
|
|
62
|
+
assert.equal(body.sessions, 1);
|
|
63
|
+
});
|
|
64
|
+
test("GET /api/sessions lists the seeded session", async () => {
|
|
65
|
+
const { status, body } = await get("/api/sessions");
|
|
66
|
+
assert.equal(status, 200);
|
|
67
|
+
assert.equal(body.length, 1);
|
|
68
|
+
assert.equal(body[0].adw_id, ADW_ID);
|
|
69
|
+
});
|
|
70
|
+
test("GET /api/sessions/:adw_id returns detail; unknown id 404s as JSON", async () => {
|
|
71
|
+
const ok = await get(`/api/sessions/${ADW_ID}`);
|
|
72
|
+
assert.equal(ok.status, 200);
|
|
73
|
+
assert.equal(ok.body.session.adw_id, ADW_ID);
|
|
74
|
+
const missing = await get("/api/sessions/does-not-exist");
|
|
75
|
+
assert.equal(missing.status, 404);
|
|
76
|
+
assert.match(missing.body.error, /no session/);
|
|
77
|
+
});
|
|
78
|
+
test("POST /api/sessions/:adw_id/archive persists; GET on the same route 404s instead of falling through to the SPA", async () => {
|
|
79
|
+
const post = await get(`/api/sessions/${ADW_ID}/archive`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ archived: true }),
|
|
83
|
+
});
|
|
84
|
+
assert.equal(post.status, 200);
|
|
85
|
+
assert.equal(post.body.archived, true);
|
|
86
|
+
const afterArchive = await get("/api/sessions");
|
|
87
|
+
assert.equal(afterArchive.body.length, 0, "archived sessions are excluded from the default list");
|
|
88
|
+
const getOnArchive = await get(`/api/sessions/${ADW_ID}/archive`);
|
|
89
|
+
assert.equal(getOnArchive.status, 404, "the deliberate behavior change from the Bun version: no silent SPA fallback here");
|
|
90
|
+
assert.match(getOnArchive.contentType, /json/);
|
|
91
|
+
});
|
|
92
|
+
test("unsafe path segments (dot-segments, encoded percent) are rejected, never resolved onto disk", async () => {
|
|
93
|
+
const encodedDotDot = await get(`/api/sessions/${ADW_ID}/agents/%2e%2e/prompts`);
|
|
94
|
+
assert.equal(encodedDotDot.status, 404); // Hono's router normalizes this away before it reaches the handler
|
|
95
|
+
const encodedPercent = await get(`/api/sessions/%2520/agents/x/prompts`);
|
|
96
|
+
assert.equal(encodedPercent.status, 400);
|
|
97
|
+
assert.match(encodedPercent.body.error, /invalid/);
|
|
98
|
+
});
|
|
99
|
+
test("unknown /api/* route 404s as JSON, not the SPA", async () => {
|
|
100
|
+
const { status, contentType } = await get("/api/nope");
|
|
101
|
+
assert.equal(status, 404);
|
|
102
|
+
assert.match(contentType, /json/);
|
|
103
|
+
});
|
|
104
|
+
test("a non-API route serves the SPA's index.html (client-side routing fallback)", async () => {
|
|
105
|
+
const { status, contentType, body } = await get("/some/client/route");
|
|
106
|
+
assert.equal(status, 200);
|
|
107
|
+
assert.match(contentType, /html/);
|
|
108
|
+
assert.match(body, /<div id="app">/);
|
|
109
|
+
});
|
|
110
|
+
test("static assets are served with immutable caching; index.html is not", async () => {
|
|
111
|
+
const index = await fetch(handle.url + "/");
|
|
112
|
+
assert.equal(index.headers.get("cache-control"), "no-store");
|
|
113
|
+
const html = await index.text();
|
|
114
|
+
const assetMatch = html.match(/src="([^"]+\.js)"/);
|
|
115
|
+
assert.ok(assetMatch, "index.html must reference a built JS asset");
|
|
116
|
+
const asset = await fetch(handle.url + "/" + assetMatch[1].replace(/^\.\//, ""));
|
|
117
|
+
assert.equal(asset.status, 200);
|
|
118
|
+
assert.match(asset.headers.get("cache-control") ?? "", /immutable/);
|
|
119
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
|
|
4
|
+
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
5
|
+
class FakeProvider {
|
|
6
|
+
entries = new Map();
|
|
7
|
+
prs = new Map();
|
|
8
|
+
ensureLabelsCalls = 0;
|
|
9
|
+
transitions = [];
|
|
10
|
+
openedPrs = [];
|
|
11
|
+
claimCalls = [];
|
|
12
|
+
nextPrNumber = 1000;
|
|
13
|
+
addIssue(number, title, state = "ready", marker = null) {
|
|
14
|
+
this.entries.set(number, { issue: { number, title, body: "", labels: [`spf:${state}`] }, state, marker });
|
|
15
|
+
}
|
|
16
|
+
async ensureLabels() {
|
|
17
|
+
this.ensureLabelsCalls++;
|
|
18
|
+
return { created: [], updated: [], unchanged: [] };
|
|
19
|
+
}
|
|
20
|
+
async listEligible() {
|
|
21
|
+
return [...this.entries.values()].filter((e) => e.state === "ready").map((e) => e.issue);
|
|
22
|
+
}
|
|
23
|
+
async listInState(state) {
|
|
24
|
+
return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
|
|
25
|
+
}
|
|
26
|
+
async claim(issue) {
|
|
27
|
+
this.claimCalls.push(issue.number);
|
|
28
|
+
const entry = this.entries.get(issue.number);
|
|
29
|
+
if (entry.state !== "ready")
|
|
30
|
+
return false;
|
|
31
|
+
entry.state = "working";
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
async transition(issue, to, detail) {
|
|
35
|
+
this.entries.get(issue.number).state = to;
|
|
36
|
+
this.transitions.push({ number: issue.number, to, detail });
|
|
37
|
+
}
|
|
38
|
+
async comment() { }
|
|
39
|
+
async openPr(issue, opts) {
|
|
40
|
+
const number = this.nextPrNumber++;
|
|
41
|
+
this.openedPrs.push({ issueNumber: issue.number, branch: opts.branch });
|
|
42
|
+
this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
|
|
43
|
+
return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
|
|
44
|
+
}
|
|
45
|
+
async prStatus(pr) {
|
|
46
|
+
return this.prs.get(pr.number) ?? { merged: false, state: "open", ciStatus: "pending" };
|
|
47
|
+
}
|
|
48
|
+
async readMarker(issue) {
|
|
49
|
+
return this.entries.get(issue.number)?.marker ?? null;
|
|
50
|
+
}
|
|
51
|
+
async writeMarker(issue, marker) {
|
|
52
|
+
this.entries.get(issue.number).marker = marker;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function fakeGit(overrides = {}) {
|
|
56
|
+
return {
|
|
57
|
+
currentBranch: () => "main",
|
|
58
|
+
createBranch: (n) => n,
|
|
59
|
+
isRepo: () => true,
|
|
60
|
+
commitAll: () => "abc123",
|
|
61
|
+
changedFiles: () => [],
|
|
62
|
+
refExists: () => true,
|
|
63
|
+
rev: () => "abc123",
|
|
64
|
+
shortSha: () => "abc123",
|
|
65
|
+
mergeBase: () => "abc123",
|
|
66
|
+
isDirty: () => false,
|
|
67
|
+
untrackedFiles: () => [],
|
|
68
|
+
diffFiles: () => [],
|
|
69
|
+
diffStat: () => "",
|
|
70
|
+
diffCounts: () => [0, 0],
|
|
71
|
+
diffText: () => "",
|
|
72
|
+
fetch: () => { },
|
|
73
|
+
worktreeAdd: () => { },
|
|
74
|
+
worktreeRemove: () => { },
|
|
75
|
+
deleteLocalBranch: () => { },
|
|
76
|
+
push: () => { },
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function makeDeps(provider, overrides = {}) {
|
|
81
|
+
return {
|
|
82
|
+
provider,
|
|
83
|
+
git: fakeGit(),
|
|
84
|
+
worktreeGit: () => fakeGit({ diffFiles: () => ["src/index.ts"] }), // a real commit landed, by default
|
|
85
|
+
labelPrefix: "spf",
|
|
86
|
+
chain: "plan-build-test",
|
|
87
|
+
baseBranch: "main",
|
|
88
|
+
concurrency: 2,
|
|
89
|
+
worktreesDir: "/tmp/spf-watch-test-worktrees",
|
|
90
|
+
dryRun: false,
|
|
91
|
+
runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
|
|
92
|
+
log: () => { },
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function waitUntil(predicate, timeoutMs = 2000) {
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
while (!predicate()) {
|
|
99
|
+
if (Date.now() - start > timeoutMs)
|
|
100
|
+
throw new Error("waitUntil timed out");
|
|
101
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
test("branchNameFor: sanitizes a title into a safe branch name", () => {
|
|
105
|
+
assert.equal(branchNameFor({ number: 42, title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
|
|
106
|
+
assert.equal(branchNameFor({ number: 7, title: "", body: "", labels: [] }), "spf-watch/7-issue");
|
|
107
|
+
});
|
|
108
|
+
test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
|
|
109
|
+
const provider = new FakeProvider();
|
|
110
|
+
provider.addIssue(1, "Add a /health endpoint");
|
|
111
|
+
const state = createWatchState();
|
|
112
|
+
const deps = makeDeps(provider);
|
|
113
|
+
await claimNewWork(deps, state);
|
|
114
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
115
|
+
assert.deepEqual(provider.claimCalls, [1]);
|
|
116
|
+
assert.equal(provider.openedPrs.length, 1);
|
|
117
|
+
assert.equal(provider.openedPrs[0].issueNumber, 1);
|
|
118
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
|
|
119
|
+
assert.equal(provider.entries.get(1).marker?.pr, 1000);
|
|
120
|
+
});
|
|
121
|
+
test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
|
|
122
|
+
const provider = new FakeProvider();
|
|
123
|
+
provider.addIssue(2, "Flaky feature");
|
|
124
|
+
const state = createWatchState();
|
|
125
|
+
const deps = makeDeps(provider, {
|
|
126
|
+
runChain: async () => ({ accepted: false, adwId: "issue-2", detail: "build-test failed at phase build" }),
|
|
127
|
+
});
|
|
128
|
+
await claimNewWork(deps, state);
|
|
129
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
130
|
+
assert.deepEqual(provider.transitions, [{ number: 2, to: "blocked", detail: "build-test failed at phase build" }]);
|
|
131
|
+
assert.equal(provider.openedPrs.length, 0);
|
|
132
|
+
});
|
|
133
|
+
test("claimNewWork: an accepted run with nothing committed also blocks, without opening a PR", async () => {
|
|
134
|
+
const provider = new FakeProvider();
|
|
135
|
+
provider.addIssue(3, "No-op request");
|
|
136
|
+
const state = createWatchState();
|
|
137
|
+
const deps = makeDeps(provider, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
|
|
138
|
+
await claimNewWork(deps, state);
|
|
139
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
140
|
+
assert.equal(provider.transitions[0]?.to, "blocked");
|
|
141
|
+
assert.match(provider.transitions[0]?.detail ?? "", /no committed changes/);
|
|
142
|
+
assert.equal(provider.openedPrs.length, 0);
|
|
143
|
+
});
|
|
144
|
+
test("claimNewWork: never claims more than the concurrency budget in one tick", async () => {
|
|
145
|
+
const provider = new FakeProvider();
|
|
146
|
+
provider.addIssue(10, "one");
|
|
147
|
+
provider.addIssue(11, "two");
|
|
148
|
+
provider.addIssue(12, "three");
|
|
149
|
+
const state = createWatchState();
|
|
150
|
+
// A runChain that never resolves keeps every claimed issue "inflight" for this assertion.
|
|
151
|
+
const deps = makeDeps(provider, { concurrency: 2, runChain: () => new Promise(() => { }) });
|
|
152
|
+
await claimNewWork(deps, state);
|
|
153
|
+
assert.equal(state.inflight.size, 2);
|
|
154
|
+
assert.equal(provider.claimCalls.length, 2);
|
|
155
|
+
});
|
|
156
|
+
test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChain", async () => {
|
|
157
|
+
const provider = new FakeProvider();
|
|
158
|
+
provider.addIssue(20, "dry run me");
|
|
159
|
+
const state = createWatchState();
|
|
160
|
+
let runChainCalled = false;
|
|
161
|
+
const deps = makeDeps(provider, {
|
|
162
|
+
dryRun: true,
|
|
163
|
+
runChain: async () => {
|
|
164
|
+
runChainCalled = true;
|
|
165
|
+
return { accepted: true, adwId: "x", detail: "" };
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await claimNewWork(deps, state);
|
|
169
|
+
assert.equal(provider.claimCalls.length, 0);
|
|
170
|
+
assert.equal(runChainCalled, false);
|
|
171
|
+
assert.equal(provider.entries.get(20).state, "ready");
|
|
172
|
+
});
|
|
173
|
+
test("reconcileOrphans: a working issue with an open PR in its marker resumes as review", async () => {
|
|
174
|
+
const provider = new FakeProvider();
|
|
175
|
+
provider.addIssue(30, "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
|
|
176
|
+
provider.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
|
|
177
|
+
const state = createWatchState();
|
|
178
|
+
await reconcileOrphans(makeDeps(provider), state);
|
|
179
|
+
assert.deepEqual(provider.transitions, [{ number: 30, to: "review", detail: undefined }]);
|
|
180
|
+
});
|
|
181
|
+
test("reconcileOrphans: a working issue with no marker retries up to the cap, then blocks", async () => {
|
|
182
|
+
const provider = new FakeProvider();
|
|
183
|
+
provider.addIssue(31, "orphaned, no marker", "working", null);
|
|
184
|
+
const state = createWatchState();
|
|
185
|
+
const deps = makeDeps(provider);
|
|
186
|
+
await reconcileOrphans(deps, state); // attempt 1 -> ready
|
|
187
|
+
assert.equal(provider.entries.get(31).state, "ready");
|
|
188
|
+
assert.equal(provider.entries.get(31).marker?.attempt, 1);
|
|
189
|
+
provider.entries.get(31).state = "working"; // simulate it getting re-claimed and orphaned again
|
|
190
|
+
await reconcileOrphans(deps, state); // attempt 2 -> ready
|
|
191
|
+
assert.equal(provider.entries.get(31).state, "ready");
|
|
192
|
+
provider.entries.get(31).state = "working";
|
|
193
|
+
await reconcileOrphans(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
|
|
194
|
+
assert.equal(provider.entries.get(31).state, "blocked");
|
|
195
|
+
assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned attempts/);
|
|
196
|
+
});
|
|
197
|
+
test("reconcileOrphans: skips issues this process is already tracking as in-flight", async () => {
|
|
198
|
+
const provider = new FakeProvider();
|
|
199
|
+
provider.addIssue(32, "actually still running", "working", null);
|
|
200
|
+
const state = createWatchState();
|
|
201
|
+
state.inflight.add(32);
|
|
202
|
+
await reconcileOrphans(makeDeps(provider), state);
|
|
203
|
+
assert.equal(provider.transitions.length, 0, "an in-flight issue must not be treated as orphaned");
|
|
204
|
+
});
|
|
205
|
+
test("finishReviews: a merged PR moves the issue to done", async () => {
|
|
206
|
+
const provider = new FakeProvider();
|
|
207
|
+
provider.addIssue(40, "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
|
|
208
|
+
provider.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
|
|
209
|
+
await finishReviews(makeDeps(provider));
|
|
210
|
+
assert.deepEqual(provider.transitions, [{ number: 40, to: "done", detail: undefined }]);
|
|
211
|
+
});
|
|
212
|
+
test("finishReviews: a closed-without-merging PR blocks the issue", async () => {
|
|
213
|
+
const provider = new FakeProvider();
|
|
214
|
+
provider.addIssue(41, "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
|
|
215
|
+
provider.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
|
|
216
|
+
await finishReviews(makeDeps(provider));
|
|
217
|
+
assert.equal(provider.transitions[0]?.to, "blocked");
|
|
218
|
+
assert.match(provider.transitions[0]?.detail ?? "", /closed without merging/);
|
|
219
|
+
});
|
|
220
|
+
test("finishReviews: a still-open PR leaves the issue in review", async () => {
|
|
221
|
+
const provider = new FakeProvider();
|
|
222
|
+
provider.addIssue(42, "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
|
|
223
|
+
provider.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
|
|
224
|
+
await finishReviews(makeDeps(provider));
|
|
225
|
+
assert.equal(provider.transitions.length, 0);
|
|
226
|
+
assert.equal(provider.entries.get(42).state, "review");
|
|
227
|
+
});
|