Story 26: Establish TDD workflow and quality gates

Add workflow engine with acceptance gates, test recording, and review
queue. Frontend displays gate status (blocked/ready), test summaries,
failing badges, and warnings. Proceed action is disabled when gates
are not met. Includes 13 unit tests (Vitest) and 9 E2E tests
(Playwright) covering all five acceptance criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 12:54:04 +00:00
parent 3a98669c4c
commit 013b28d77f
31 changed files with 3627 additions and 417 deletions

View File

@@ -1,3 +1,4 @@
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
use crate::state::SessionState;
use crate::store::StoreOps;
use serde::Serialize;
@@ -276,6 +277,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
* **Frontend:** TypeScript + React
* **Build Tool:** Vite
* **Package Manager:** pnpm (required)
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
* **State Management:** React Context / Hooks
* **Chat UI:** Rendered Markdown with syntax highlighting.
@@ -394,6 +396,34 @@ fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, Stri
Ok(root.join(relative_path))
}
fn is_story_kit_path(path: &str) -> bool {
path == ".story_kit" || path.starts_with(".story_kit/")
}
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
let approved = tokio::task::spawn_blocking(move || {
let story_path = root
.join(".story_kit")
.join("stories")
.join("current")
.join("26_establish_tdd_workflow_and_gates.md");
let contents = fs::read_to_string(&story_path)
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
})
.await
.map_err(|e| format!("Task failed: {e}"))??;
if approved {
Ok(())
} else {
Err("Test plan is not approved for the current story.".to_string())
}
}
/// Resolves a relative path against the active project root.
/// Returns error if no project is open or if path attempts traversal (..).
fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
@@ -597,7 +627,11 @@ async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), Stri
}
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
let full_path = resolve_path(state, &path)?;
let root = state.get_project_root()?;
if !is_story_kit_path(&path) {
ensure_test_plan_approved(root.clone()).await?;
}
let full_path = resolve_path_impl(root, &path)?;
write_file_impl(full_path, content).await
}
@@ -658,3 +692,27 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
.await
.map_err(|e| format!("Task failed: {}", e))?
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn write_file_requires_approved_test_plan() {
let dir = tempdir().expect("tempdir");
let state = SessionState::default();
{
let mut root = state.project_root.lock().expect("lock project root");
*root = Some(dir.path().to_path_buf());
}
let result = write_file("notes.txt".to_string(), "hello".to_string(), &state).await;
assert!(
result.is_err(),
"expected write to be blocked when test plan is not approved"
);
}
}

View File

@@ -1,3 +1,4 @@
pub mod fs;
pub mod search;
pub mod shell;
pub mod story_metadata;

View File

@@ -1,5 +1,7 @@
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
use crate::state::SessionState;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
@@ -8,6 +10,30 @@ fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
state.get_project_root()
}
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
let approved = tokio::task::spawn_blocking(move || {
let story_path = root
.join(".story_kit")
.join("stories")
.join("current")
.join("26_establish_tdd_workflow_and_gates.md");
let contents = fs::read_to_string(&story_path)
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
})
.await
.map_err(|e| format!("Task failed: {e}"))??;
if approved {
Ok(())
} else {
Err("Test plan is not approved for the current story.".to_string())
}
}
#[derive(Serialize, Debug, poem_openapi::Object)]
pub struct CommandOutput {
pub stdout: String,
@@ -54,5 +80,30 @@ pub async fn exec_shell(
state: &SessionState,
) -> Result<CommandOutput, String> {
let root = get_project_root(state)?;
ensure_test_plan_approved(root.clone()).await?;
exec_shell_impl(command, args, root).await
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn exec_shell_requires_approved_test_plan() {
let dir = tempdir().expect("tempdir");
let state = SessionState::default();
{
let mut root = state.project_root.lock().expect("lock project root");
*root = Some(dir.path().to_path_buf());
}
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
assert!(
result.is_err(),
"expected shell execution to be blocked when test plan is not approved"
);
}
}

View File

@@ -0,0 +1,111 @@
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestPlanStatus {
Approved,
WaitingForApproval,
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata {
pub name: Option<String>,
pub test_plan: Option<TestPlanStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
#[derive(Debug, Deserialize)]
struct FrontMatter {
name: Option<String>,
test_plan: Option<String>,
}
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
let trimmed = line.trim();
if trimmed == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(build_metadata(front));
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
fn build_metadata(front: FrontMatter) -> StoryMetadata {
let test_plan = front.test_plan.as_deref().map(parse_test_plan_status);
StoryMetadata {
name: front.name,
test_plan,
}
}
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
match value {
"approved" => TestPlanStatus::Approved,
"waiting_for_approval" => TestPlanStatus::WaitingForApproval,
other => TestPlanStatus::Unknown(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_front_matter_metadata() {
let input = r#"---
name: Establish the TDD Workflow and Gates
test_plan: approved
workflow: tdd
---
# Story 26
"#;
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(
meta,
StoryMetadata {
name: Some("Establish the TDD Workflow and Gates".to_string()),
test_plan: Some(TestPlanStatus::Approved),
}
);
}
#[test]
fn rejects_missing_front_matter() {
let input = "# Story 26\n";
assert_eq!(
parse_front_matter(input),
Err(StoryMetaError::MissingFrontMatter)
);
}
#[test]
fn rejects_unclosed_front_matter() {
let input = "---\nname: Test\n";
assert!(matches!(
parse_front_matter(input),
Err(StoryMetaError::InvalidFrontMatter(_))
));
}
}