feat: unified testing framework — unit and e2e same syntax, seed-based graph testing, debugger infrastructure

- New crate el-test: test discovery, in-memory graph seeding, assertion evaluator, TestRunner, TestReport with human/JSON/JUnit XML output
- New keywords: test, seed, assert, target — fully integrated into lexer, parser, codegen, type-checker
- Parser extensions: TestDef, SeedStmt, Assert AST nodes; seed blocks handle type: as field name (keyword-as-ident in seed context)
- Debugger: DebugEvent, Debugger, StepMode, StackFrame in el-compiler — breakpoints, step-over, step-into, step-out
- CLI: el test-file <file.el> runs tests; el test integrates with project; el debug attaches debugger; --output json|junit for CI
- 52 new tests in el-test covering discovery, graph seeding, assertion evaluation, pass/fail/error/skip, report generation, JUnit XML
- Example: examples/hello-project/src/tests.el — 6 unit tests pass, 1 e2e test correctly skipped without ENGRAM_URL
This commit is contained in:
Will Anderson
2026-04-27 19:11:59 -05:00
parent 48b72843e1
commit 0a36a454f9
14 changed files with 2177 additions and 14 deletions
+34 -2
View File
@@ -84,6 +84,38 @@ impl Parser {
}
}
/// Like `expect_ident` but also accepts keywords as bare names.
/// Used in contexts like seed field names where `type:` must work.
fn expect_ident_or_keyword(&mut self) -> Result<(String, Span), ParseError> {
let span = self.peek_span();
let name = match self.peek().clone() {
Token::Ident(name) => name,
// Accept any keyword as an identifier in seed field position
Token::Type => "type".to_string(),
Token::Fn => "fn".to_string(),
Token::Let => "let".to_string(),
Token::Enum => "enum".to_string(),
Token::Match => "match".to_string(),
Token::Return => "return".to_string(),
Token::Activate => "activate".to_string(),
Token::Where => "where".to_string(),
Token::Sealed => "sealed".to_string(),
Token::If => "if".to_string(),
Token::Else => "else".to_string(),
Token::For => "for".to_string(),
Token::In => "in".to_string(),
Token::Seed => "seed".to_string(),
Token::Assert => "assert".to_string(),
Token::Target => "target".to_string(),
tok => return Err(ParseError::new(
ParseErrorKind::ExpectedIdent(tok.to_string()),
span,
)),
};
self.advance();
Ok((name, span))
}
fn eat(&mut self, tok: &Token) -> bool {
if self.peek() == tok {
self.advance();
@@ -178,7 +210,7 @@ impl Parser {
let mut tier: Option<String> = None;
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
let (field_name, _) = self.expect_ident()?;
let (field_name, _) = self.expect_ident_or_keyword()?;
self.expect(&Token::Colon)?;
match field_name.as_str() {
"type" => {
@@ -221,7 +253,7 @@ impl Parser {
let mut weight: f32 = 1.0;
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
let (field_name, _) = self.expect_ident()?;
let (field_name, _) = self.expect_ident_or_keyword()?;
self.expect(&Token::Colon)?;
match field_name.as_str() {
"from" => {