forked from bkinnightskytw/code_work_spawner
feat: add Gherkin test validation and enforce doc-comment format
This commit is contained in:
parent
da87c670ae
commit
aa8d122ab9
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: unittest
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dart:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dart-lang/setup-dart@v1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: dart pub get
|
||||||
|
|
||||||
|
- name: Static analysis
|
||||||
|
run: dart analyze
|
||||||
|
|
||||||
|
- name: Validate Gherkin test format
|
||||||
|
run: dart run tool/validate_gherkin_test_format.dart
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: dart test
|
||||||
|
|
@ -4,3 +4,10 @@
|
||||||
pubspec.lock
|
pubspec.lock
|
||||||
.code_work_spawner.sqlite3
|
.code_work_spawner.sqlite3
|
||||||
agent-orchestrator.yaml
|
agent-orchestrator.yaml
|
||||||
|
/.*
|
||||||
|
# !/.agents
|
||||||
|
!/.git
|
||||||
|
!/.github
|
||||||
|
/skills/*
|
||||||
|
!/skills/dart-gherkin-tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Repo Instructions
|
||||||
|
|
||||||
|
- `lefthook install` to set up pre-commit hooks
|
||||||
|
- For any new or modified Dart test under `test/`, use the repo-local skill `dart-gherkin-tests`.
|
||||||
13
README.md
13
README.md
|
|
@ -10,8 +10,7 @@ issue comments, and uses configured CLI responders such as `codex`,
|
||||||
- a new comment changes the thread enough that the responder decides a reply is
|
- a new comment changes the thread enough that the responder decides a reply is
|
||||||
useful
|
useful
|
||||||
|
|
||||||
It supports multi-repo configuration, responder fallback chains, Drift-backed
|
It supports multi-repo configuration, responder fallback chains.
|
||||||
SQLite state, and BDD-style Dart tests.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -93,10 +92,18 @@ dart run bin/code_work_spawner.dart \
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart test
|
dart run tool/validate_gherkin_test_format.dart
|
||||||
dart analyze
|
dart analyze
|
||||||
|
dart test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All Dart tests under `test/` must follow the repo's Gherkin doc-comment format:
|
||||||
|
|
||||||
|
- `group(...)` has a `Feature` doc block above it
|
||||||
|
- `test(...)` has a `Scenario` doc block above it
|
||||||
|
- runtime names stay plain, without `Feature:` or `Scenario:` prefixes
|
||||||
|
- test bodies include `Given` / `When` / `Then` inline comments
|
||||||
|
|
||||||
## ref
|
## ref
|
||||||
|
|
||||||
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)
|
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
pre-commit:
|
||||||
|
commands:
|
||||||
|
format:
|
||||||
|
run: dart format --output=none --set-exit-if-changed .
|
||||||
|
analyze:
|
||||||
|
run: dart analyze
|
||||||
|
test:
|
||||||
|
run: dart test
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class GherkinValidationIssue {
|
||||||
|
GherkinValidationIssue({
|
||||||
|
required this.path,
|
||||||
|
required this.line,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String path;
|
||||||
|
final int line;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$path:$line: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class GherkinTestValidator {
|
||||||
|
List<GherkinValidationIssue> validateDirectory(Directory testDirectory) {
|
||||||
|
if (!testDirectory.existsSync()) {
|
||||||
|
return const <GherkinValidationIssue>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final issues = <GherkinValidationIssue>[];
|
||||||
|
final files =
|
||||||
|
testDirectory
|
||||||
|
.listSync(recursive: true)
|
||||||
|
.whereType<File>()
|
||||||
|
.where((file) => file.path.endsWith('_test.dart'))
|
||||||
|
.toList()
|
||||||
|
..sort((left, right) => left.path.compareTo(right.path));
|
||||||
|
|
||||||
|
for (final file in files) {
|
||||||
|
issues.addAll(validateFile(file));
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GherkinValidationIssue> validateFile(File file) {
|
||||||
|
final lines = file.readAsLinesSync();
|
||||||
|
final issues = <GherkinValidationIssue>[];
|
||||||
|
var inMultilineString = false;
|
||||||
|
|
||||||
|
for (var index = 0; index < lines.length; index++) {
|
||||||
|
final line = lines[index];
|
||||||
|
if (_isInsideMultilineString(line, inMultilineString)) {
|
||||||
|
inMultilineString = !inMultilineString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inMultilineString) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_isGroupDeclaration(line)) {
|
||||||
|
issues.addAll(_validateGroup(file.path, lines, index));
|
||||||
|
}
|
||||||
|
if (_isTestDeclaration(line)) {
|
||||||
|
issues.addAll(_validateTest(file.path, lines, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GherkinValidationIssue> _validateGroup(
|
||||||
|
String path,
|
||||||
|
List<String> lines,
|
||||||
|
int declarationIndex,
|
||||||
|
) {
|
||||||
|
final issues = <GherkinValidationIssue>[];
|
||||||
|
final docBlock = _precedingDocBlock(lines, declarationIndex);
|
||||||
|
if (docBlock.isEmpty) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message:
|
||||||
|
'group(...) must be preceded by a Gherkin Feature doc block.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final content = docBlock.join('\n');
|
||||||
|
if (!content.contains('```gherkin')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Feature doc block must use ```gherkin fencing.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.contains('Feature:')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Feature doc block must contain "Feature:".',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final runtimeName = _extractFirstStringArgument(lines[declarationIndex]);
|
||||||
|
if (runtimeName != null && runtimeName.startsWith('Feature:')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Runtime group name must not start with "Feature:".',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GherkinValidationIssue> _validateTest(
|
||||||
|
String path,
|
||||||
|
List<String> lines,
|
||||||
|
int declarationIndex,
|
||||||
|
) {
|
||||||
|
final issues = <GherkinValidationIssue>[];
|
||||||
|
final docBlock = _precedingDocBlock(lines, declarationIndex);
|
||||||
|
if (docBlock.isEmpty) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message:
|
||||||
|
'test(...) must be preceded by a Gherkin Scenario doc block.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final content = docBlock.join('\n');
|
||||||
|
if (!content.contains('```gherkin')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Scenario doc block must use ```gherkin fencing.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.contains('Scenario:')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Scenario doc block must contain "Scenario:".',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.contains('Given ')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Scenario doc block must contain a Given step.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.contains('When ')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Scenario doc block must contain a When step.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!content.contains('Then ')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Scenario doc block must contain a Then step.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final runtimeName = _extractFirstStringArgument(lines[declarationIndex]);
|
||||||
|
if (runtimeName != null && runtimeName.startsWith('Scenario:')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Runtime test name must not start with "Scenario:".',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = _extractTestBody(lines, declarationIndex);
|
||||||
|
if (!body.contains('// Given')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Test body must contain a "// Given" comment.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!body.contains('// When')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Test body must contain a "// When" comment.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!body.contains('// Then')) {
|
||||||
|
issues.add(
|
||||||
|
GherkinValidationIssue(
|
||||||
|
path: path,
|
||||||
|
line: declarationIndex + 1,
|
||||||
|
message: 'Test body must contain a "// Then" comment.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _precedingDocBlock(List<String> lines, int declarationIndex) {
|
||||||
|
final block = <String>[];
|
||||||
|
var cursor = declarationIndex - 1;
|
||||||
|
|
||||||
|
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
|
||||||
|
cursor--;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cursor >= 0 && lines[cursor].trimLeft().startsWith('///')) {
|
||||||
|
block.insert(0, lines[cursor]);
|
||||||
|
cursor--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractTestBody(List<String> lines, int declarationIndex) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var braceDepth = 0;
|
||||||
|
var started = false;
|
||||||
|
|
||||||
|
for (var index = declarationIndex; index < lines.length; index++) {
|
||||||
|
final line = lines[index];
|
||||||
|
for (final rune in line.runes) {
|
||||||
|
final char = String.fromCharCode(rune);
|
||||||
|
if (char == '{') {
|
||||||
|
braceDepth++;
|
||||||
|
started = true;
|
||||||
|
} else if (char == '}') {
|
||||||
|
braceDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (started) {
|
||||||
|
buffer.writeln(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (started && braceDepth <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractFirstStringArgument(String line) {
|
||||||
|
final match = RegExp(r"(?:group|test)\(\s*'([^']*)'").firstMatch(line);
|
||||||
|
return match?.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isGroupDeclaration(String line) =>
|
||||||
|
RegExp(r'^\s*group\(').hasMatch(line.trimLeft());
|
||||||
|
|
||||||
|
bool _isTestDeclaration(String line) =>
|
||||||
|
RegExp(r'^\s*test\(').hasMatch(line.trimLeft());
|
||||||
|
|
||||||
|
bool _isInsideMultilineString(String line, bool currentlyInside) {
|
||||||
|
final tripleSingle = "'''".allMatches(line).length;
|
||||||
|
final tripleDouble = '"""'.allMatches(line).length;
|
||||||
|
final toggles = tripleSingle + tripleDouble;
|
||||||
|
if (toggles == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentlyInside && toggles.isOdd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentlyInside && toggles.isOdd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"create-github-action-workflow-specification": {
|
||||||
|
"source": "github/awesome-copilot",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "527e6c77d921d58a9d0059aa95d0d14b441d2166f9c20518263048a56ca8fbbb"
|
||||||
|
},
|
||||||
|
"dart-api-design": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "d60beeedc91e1be7c51d7fea38acb636c0519b8140159402afcf69f31846cfba"
|
||||||
|
},
|
||||||
|
"dart-async-programming": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "ebff987d2ce82a0c0eebab1b7ebc724d29bb925c56273ee55775550d634a3c72"
|
||||||
|
},
|
||||||
|
"dart-code-generation": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "259e371481a822984e666cc5214c97ac1581a0adbbdb4d7cab3997c9b1de2009"
|
||||||
|
},
|
||||||
|
"dart-compilation-deployment": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "862ea7fd5efb9bd9760d9eab58880a1f08bb9b64125f171b778ba6918f79980f"
|
||||||
|
},
|
||||||
|
"dart-concurrency-isolates": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "829a819bb37342813eca318e0008d93db83aa313daa58be1b9a508d5b1674a9f"
|
||||||
|
},
|
||||||
|
"dart-documentation": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "0016010518730eb2aeaabbf5046667deb724040be855a5892db4a9735608ca75"
|
||||||
|
},
|
||||||
|
"dart-effective-style": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "ceaa8cbe82090bf759d763cdc83aa80df79ac2461d0d5f223f63e72f60901ae4"
|
||||||
|
},
|
||||||
|
"dart-idiomatic-usage": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "ac383cc321ce8eeac6412eecc466e68adfcb131fd98c5b74af9d4b33c64381bf"
|
||||||
|
},
|
||||||
|
"dart-language-syntax": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "d1b1f4027530bb736247f62135845c06f69148cd3464fc235f942324499aee54"
|
||||||
|
},
|
||||||
|
"dart-migration-versioning": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "c3fefb316b55d7794a49bc226cee17a2fafc00d75621e23aee1dd3f8c15def3a"
|
||||||
|
},
|
||||||
|
"dart-native-interop-ffi": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "cac814c48f43b964471b41e99622b471203bb2900845bcc03eca02b2a3162ed5"
|
||||||
|
},
|
||||||
|
"dart-package-management": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "a1fbb5422c3d9bf980a310b55f8cb938b969234eda60a27038f65833e5d0e805"
|
||||||
|
},
|
||||||
|
"dart-static-analysis": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "f13d7c751ed2d026da741fe7b2535fb11a8d045c48ee91b48c0bf7b8147a3758"
|
||||||
|
},
|
||||||
|
"dart-testing": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "c6857b34f27d704eadbb770e40512693e18e04bab7345c23818386a6996dea6a"
|
||||||
|
},
|
||||||
|
"dart-web-development": {
|
||||||
|
"source": "dart-lang/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "2adc4b77a7d30a2c9c52fc96368df10a8242512c95c8bf2b3fc06abf208dc421"
|
||||||
|
},
|
||||||
|
"gh-cli": {
|
||||||
|
"source": "github/awesome-copilot",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "cbe644b0c6760ae2a1eaafa39133920d889331327065d92a131675a665273e56"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
name: dart-gherkin-tests
|
||||||
|
description: Use when creating or editing Dart tests in this repo. Enforces the repository's required Gherkin-style doc comments for `group(...)` and `test(...)`, plus inline Given/When/Then comments inside test bodies.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dart Gherkin Tests
|
||||||
|
|
||||||
|
Use this skill for any new or modified file under `test/`.
|
||||||
|
|
||||||
|
## Required format
|
||||||
|
|
||||||
|
Every `group(...)` must be preceded by a Gherkin doc block:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: AppConfig loading
|
||||||
|
///
|
||||||
|
/// As a user of the Code Work Spawner
|
||||||
|
/// I want the app configuration to correctly merge defaults with project-specific overrides
|
||||||
|
/// So that I can specify common settings once and customize only what differs for each project
|
||||||
|
/// ```
|
||||||
|
group('AppConfig.load', () {
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `test(...)` must be preceded by a Gherkin doc block:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Merge defaults with project overrides
|
||||||
|
/// Given AO style config with shared defaults and one project override
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the project keeps inherited defaults that were not overridden
|
||||||
|
/// And the project-specific prompt replaces the shared prompt
|
||||||
|
/// ```
|
||||||
|
test('Merge defaults with project overrides', () async {
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the test body, use inline phase comments:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Given AO style config with shared defaults and one project override.
|
||||||
|
...
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
...
|
||||||
|
|
||||||
|
// Then the project keeps inherited defaults that were not overridden.
|
||||||
|
...
|
||||||
|
|
||||||
|
// And the project-specific prompt replaces the shared prompt.
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Do add `Feature:` and `Scenario:` only in doc comments.
|
||||||
|
- Do keep runtime `group(...)` names code-oriented.
|
||||||
|
- Do keep runtime `test(...)` names as the scenario title only.
|
||||||
|
- Do use inline `// Given`, `// When`, and `// Then` comments in every test body.
|
||||||
|
- Do use `// And` when a scenario has multiple assertions or setup steps.
|
||||||
|
- Do not put `Feature:` in the `group(...)` string.
|
||||||
|
- Do not put `Scenario:` in the `test(...)` string.
|
||||||
|
- Do not add tests under `test/` without the required Gherkin doc-comment blocks.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before finishing test work in this repo:
|
||||||
|
|
||||||
|
1. Confirm every `group(...)` has a `Feature` doc block directly above it.
|
||||||
|
2. Confirm every `test(...)` has a `Scenario` doc block directly above it.
|
||||||
|
3. Confirm every test body has `Given`, `When`, and `Then` inline comments.
|
||||||
|
4. Run `dart run tool/validate_gherkin_test_format.dart`.
|
||||||
|
|
@ -9,15 +9,25 @@ import 'package:test/test.dart';
|
||||||
void main() {
|
void main() {
|
||||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: AppConfig loading
|
||||||
|
///
|
||||||
|
/// As a user of the Code Work Spawner
|
||||||
|
/// I want the app configuration to correctly merge defaults with project-specific overrides
|
||||||
|
/// So that I can specify common settings once and customize only what differs for each project
|
||||||
|
/// ```
|
||||||
group('AppConfig.load', () {
|
group('AppConfig.load', () {
|
||||||
test(
|
/// ```gherkin
|
||||||
'given AO style config when loading then defaults and project overrides are merged',
|
/// Scenario: Merge defaults with project overrides
|
||||||
() async {
|
/// Given AO style config with shared defaults and one project override
|
||||||
// Given a config file with shared defaults and a project override.
|
/// When loading the app config from disk
|
||||||
|
/// Then the project keeps inherited defaults that were not overridden
|
||||||
|
/// And the project-specific prompt replaces the shared prompt
|
||||||
|
/// ```
|
||||||
|
test('Merge defaults with project overrides', () async {
|
||||||
|
// Given AO style config with shared defaults and one project override.
|
||||||
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
||||||
final configFile = File(
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
p.join(tempDir.path, 'agent-orchestrator.yaml'),
|
|
||||||
);
|
|
||||||
await configFile.writeAsString('''
|
await configFile.writeAsString('''
|
||||||
defaults:
|
defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
|
|
@ -37,35 +47,51 @@ projects:
|
||||||
prompt: "project {{project_key}}"
|
prompt: "project {{project_key}}"
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// When the app config is loaded from disk.
|
// When loading the app config from disk.
|
||||||
final config = await AppConfig.load(configFile.path);
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
// Then the project inherits defaults and overrides only the prompt.
|
// Then the project keeps inherited defaults that were not overridden.
|
||||||
expect(config.projects['sample'], isNotNull);
|
expect(config.projects['sample'], isNotNull);
|
||||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||||
'@helper',
|
'@helper',
|
||||||
]);
|
]);
|
||||||
expect(
|
|
||||||
config.projects['sample']!.issueAssistant.prompt,
|
|
||||||
'project {{project_key}}',
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
config.projects['sample']!.issueAssistant.responders.single.id,
|
config.projects['sample']!.issueAssistant.responders.single.id,
|
||||||
'fallback',
|
'fallback',
|
||||||
);
|
);
|
||||||
},
|
|
||||||
|
// And the project-specific prompt replaces the shared prompt.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.prompt,
|
||||||
|
'project {{project_key}}',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: Issue thread assistant polling
|
||||||
|
///
|
||||||
|
/// As a maintainer using the issue assistant
|
||||||
|
/// I want issue thread events to be evaluated through configured responders
|
||||||
|
/// So that users get help only when the thread actually needs a response
|
||||||
|
/// ```
|
||||||
group('IssueAssistantApp.runOnce', () {
|
group('IssueAssistantApp.runOnce', () {
|
||||||
test(
|
/// ```gherkin
|
||||||
'given an explicit mention when polling then the app posts the responder reply once',
|
/// Scenario: Reply once when a user explicitly mentions the assistant
|
||||||
() async {
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
// Given a fake gh client and a fake responder that emits a planning reply.
|
/// And GitHub issue data containing a comment with an explicit assistant mention
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then it reads issue comments and posts exactly one reply for that issue
|
||||||
|
/// ```
|
||||||
|
test('Reply once when a user explicitly mentions the assistant', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-run-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-run-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data containing a comment with an explicit assistant mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
final issueData = [
|
final issueData = [
|
||||||
|
|
@ -92,51 +118,25 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
final replyJson = jsonEncode({
|
await _writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {12: commentsData},
|
||||||
|
postCommentIssueNumber: 12,
|
||||||
|
ghLog: ghLog,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that emits a planning reply.
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await _writeResponderScript(responderScript, {
|
||||||
'decision': 'reply',
|
'decision': 'reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'markdown': 'Plan:\n- separate service and persistence',
|
'markdown': 'Plan:\n- separate service and persistence',
|
||||||
'summary': 'posted planning advice',
|
'summary': 'posted planning advice',
|
||||||
});
|
});
|
||||||
await ghScript.writeAsString('''#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf '%s\n' "\$*" >> "${ghLog.path}"
|
|
||||||
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then
|
|
||||||
cat <<'JSON'
|
|
||||||
${jsonEncode(issueData)}
|
|
||||||
JSON
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?per_page=100" ]]; then
|
|
||||||
cat <<'JSON'
|
|
||||||
${jsonEncode(commentsData)}
|
|
||||||
JSON
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then
|
|
||||||
cat <<'JSON'
|
|
||||||
{"id": 700, "html_url": "https://example.test/comment/700", "body": "posted"}
|
|
||||||
JSON
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "unexpected gh args: \$*" >&2
|
|
||||||
exit 1
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', ghScript.path]);
|
|
||||||
|
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
set -euo pipefail
|
|
||||||
cat >/dev/null
|
|
||||||
cat <<'JSON'
|
|
||||||
$replyJson
|
|
||||||
JSON
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
|
||||||
|
|
||||||
final configFile = File(
|
|
||||||
p.join(sandbox.path, 'agent-orchestrator.yaml'),
|
|
||||||
);
|
|
||||||
await configFile.writeAsString('''
|
await configFile.writeAsString('''
|
||||||
defaults:
|
defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
|
|
@ -153,40 +153,47 @@ projects:
|
||||||
path: ${repoDir.path}
|
path: ${repoDir.path}
|
||||||
defaultBranch: main
|
defaultBranch: main
|
||||||
''');
|
''');
|
||||||
|
|
||||||
final config = await AppConfig.load(configFile.path);
|
|
||||||
final app = await IssueAssistantApp.open(
|
final app = await IssueAssistantApp.open(
|
||||||
config: config,
|
config: await AppConfig.load(configFile.path),
|
||||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
ghCommand: ghScript.path,
|
ghCommand: ghScript.path,
|
||||||
);
|
);
|
||||||
|
|
||||||
// When the app processes a single polling cycle.
|
// When the app processes one polling cycle.
|
||||||
await app.runOnce();
|
await app.runOnce();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
// Then a GitHub comment is posted for the mentioned issue.
|
// Then it reads issue comments and posts exactly one reply for that issue.
|
||||||
final logLines = await ghLog.readAsLines();
|
final logLines = await ghLog.readAsLines();
|
||||||
expect(
|
expect(
|
||||||
logLines
|
logLines
|
||||||
.where(
|
.where(
|
||||||
(line) =>
|
(line) => line.contains('repos/owner/sample/issues/12/comments'),
|
||||||
line.contains('repos/owner/sample/issues/12/comments'),
|
|
||||||
)
|
)
|
||||||
.length,
|
.length,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Record no reply when the responder decides the discussion is already sufficient
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And GitHub issue data with a new comment that does not require another response
|
||||||
|
/// And a responder that explicitly returns no_reply
|
||||||
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then it records the evaluation result as no_reply
|
||||||
|
/// And it does not store a posted GitHub comment for that thread version
|
||||||
|
/// ```
|
||||||
test(
|
test(
|
||||||
'given a thread update without a useful delta when polling then the app records no reply',
|
'Record no reply when the responder decides the discussion is already sufficient',
|
||||||
() async {
|
() async {
|
||||||
// Given a fake gh client and a responder that explicitly returns no_reply.
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
|
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
await _initGitRepo(repoDir);
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data with a new comment that does not require another response.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
final issueData = [
|
final issueData = [
|
||||||
{
|
{
|
||||||
|
|
@ -210,40 +217,21 @@ projects:
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
final noReplyJson = jsonEncode({
|
await _writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {42: commentsData},
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that explicitly returns no_reply.
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await _writeResponderScript(responderScript, {
|
||||||
'decision': 'no_reply',
|
'decision': 'no_reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
'summary': 'thread already resolved',
|
'summary': 'thread already resolved',
|
||||||
});
|
});
|
||||||
await ghScript.writeAsString('''#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then
|
|
||||||
cat <<'JSON'
|
|
||||||
${jsonEncode(issueData)}
|
|
||||||
JSON
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/42/comments?per_page=100" ]]; then
|
|
||||||
cat <<'JSON'
|
|
||||||
${jsonEncode(commentsData)}
|
|
||||||
JSON
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "unexpected gh args: \$*" >&2
|
|
||||||
exit 1
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', ghScript.path]);
|
|
||||||
|
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
cat >/dev/null
|
|
||||||
cat <<'JSON'
|
|
||||||
$noReplyJson
|
|
||||||
JSON
|
|
||||||
''');
|
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
|
||||||
|
|
||||||
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
final configFile = File(
|
final configFile = File(
|
||||||
p.join(sandbox.path, 'agent-orchestrator.yaml'),
|
p.join(sandbox.path, 'agent-orchestrator.yaml'),
|
||||||
);
|
);
|
||||||
|
|
@ -261,27 +249,110 @@ projects:
|
||||||
repo: owner/sample
|
repo: owner/sample
|
||||||
path: ${repoDir.path}
|
path: ${repoDir.path}
|
||||||
''');
|
''');
|
||||||
|
|
||||||
final app = await IssueAssistantApp.open(
|
final app = await IssueAssistantApp.open(
|
||||||
config: await AppConfig.load(configFile.path),
|
config: await AppConfig.load(configFile.path),
|
||||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
ghCommand: ghScript.path,
|
ghCommand: ghScript.path,
|
||||||
);
|
);
|
||||||
|
|
||||||
// When a poll cycle evaluates the unchanged discussion.
|
// When the app processes one polling cycle.
|
||||||
await app.runOnce();
|
await app.runOnce();
|
||||||
final thread = await app.database.findIssueThread('sample', 42);
|
final thread = await app.database.findIssueThread('sample', 42);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
// Then the issue is marked as evaluated without storing a comment.
|
// Then it records the evaluation result as no_reply.
|
||||||
expect(thread, isNotNull);
|
expect(thread, isNotNull);
|
||||||
expect(thread!.lastDecision, 'no_reply');
|
expect(thread!.lastDecision, 'no_reply');
|
||||||
|
|
||||||
|
// And it does not store a posted GitHub comment for that thread version.
|
||||||
expect(thread.lastCommentId, isNull);
|
expect(thread.lastCommentId, isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _writeFakeGhScript({
|
||||||
|
required File ghScript,
|
||||||
|
required List<Map<String, Object?>> issueListResponse,
|
||||||
|
required Map<int, List<Map<String, Object?>>> commentResponses,
|
||||||
|
File? ghLog,
|
||||||
|
int? postCommentIssueNumber,
|
||||||
|
}) async {
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('#!/usr/bin/env bash')
|
||||||
|
..writeln('set -euo pipefail');
|
||||||
|
|
||||||
|
if (ghLog != null) {
|
||||||
|
buffer.writeln(
|
||||||
|
r'''printf '%s\n' "$*" >> '''
|
||||||
|
'"${ghLog.path}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
r'''if [[ "$1" == "api" && "$4" == repos/owner/sample/issues\?* ]]; then''',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode(issueListResponse))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
for (final entry in commentResponses.entries) {
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode(entry.value))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postCommentIssueNumber != null) {
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/owner/sample/issues/$postCommentIssueNumber/comments" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(
|
||||||
|
jsonEncode({
|
||||||
|
'id': 700,
|
||||||
|
'html_url': 'https://example.test/comment/700',
|
||||||
|
'body': 'posted',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||||
|
..writeln('exit 1');
|
||||||
|
|
||||||
|
await ghScript.writeAsString(buffer.toString());
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeResponderScript(
|
||||||
|
File responderScript,
|
||||||
|
Map<String, Object?> response,
|
||||||
|
) async {
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat >/dev/null
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(response)}
|
||||||
|
JSON
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initGitRepo(Directory directory) async {
|
Future<void> _initGitRepo(Directory directory) async {
|
||||||
await Process.run('git', ['init'], workingDirectory: directory.path);
|
await Process.run('git', ['init'], workingDirectory: directory.path);
|
||||||
await Process.run('git', [
|
await Process.run('git', [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/src/gherkin_test_validator.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: Gherkin test validator
|
||||||
|
///
|
||||||
|
/// As a maintainer of the repository
|
||||||
|
/// I want test files to be validated against the required Gherkin comment format
|
||||||
|
/// So that future tests stay consistent and reviewable
|
||||||
|
/// ```
|
||||||
|
group('GherkinTestValidator', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Accept a correctly formatted test file
|
||||||
|
/// Given a Dart test file that follows the required Feature and Scenario doc-comment structure
|
||||||
|
/// When the validator checks that file
|
||||||
|
/// Then it reports no validation issues
|
||||||
|
/// ```
|
||||||
|
test('Accept a correctly formatted test file', () async {
|
||||||
|
// Given a Dart test file that follows the required Feature and Scenario doc-comment structure.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('gherkin-good-');
|
||||||
|
final testFile = File(p.join(tempDir.path, 'good_test.dart'));
|
||||||
|
await testFile.writeAsString(_goodTestFile);
|
||||||
|
final validator = GherkinTestValidator();
|
||||||
|
|
||||||
|
// When the validator checks that file.
|
||||||
|
final issues = validator.validateFile(testFile);
|
||||||
|
|
||||||
|
// Then it reports no validation issues.
|
||||||
|
expect(issues, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reject a test file that does not follow the required format
|
||||||
|
/// Given a Dart test file that uses plain test declarations without the required Gherkin doc comments
|
||||||
|
/// When the validator checks that file
|
||||||
|
/// Then it reports validation issues for the missing structure
|
||||||
|
/// ```
|
||||||
|
test('Reject a test file that does not follow the required format', () async {
|
||||||
|
// Given a Dart test file that uses plain test declarations without the required Gherkin doc comments.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('gherkin-bad-');
|
||||||
|
final testFile = File(p.join(tempDir.path, 'bad_test.dart'));
|
||||||
|
await testFile.writeAsString(_badTestFile);
|
||||||
|
final validator = GherkinTestValidator();
|
||||||
|
|
||||||
|
// When the validator checks that file.
|
||||||
|
final issues = validator.validateFile(testFile);
|
||||||
|
|
||||||
|
// Then it reports validation issues for the missing structure.
|
||||||
|
expect(issues, isNotEmpty);
|
||||||
|
expect(
|
||||||
|
issues.map((issue) => issue.message),
|
||||||
|
contains('group(...) must be preceded by a Gherkin Feature doc block.'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
issues.map((issue) => issue.message),
|
||||||
|
contains('test(...) must be preceded by a Gherkin Scenario doc block.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const String _goodTestFile = r'''
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: Example feature
|
||||||
|
///
|
||||||
|
/// As a user
|
||||||
|
/// I want readable tests
|
||||||
|
/// So that I can understand their behavior quickly
|
||||||
|
/// ```
|
||||||
|
group('ExampleGroup', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Run a simple assertion
|
||||||
|
/// Given a valid example
|
||||||
|
/// When the test is executed
|
||||||
|
/// Then the assertion succeeds
|
||||||
|
/// ```
|
||||||
|
test('Run a simple assertion', () {
|
||||||
|
// Given a valid example.
|
||||||
|
final value = 1;
|
||||||
|
|
||||||
|
// When the test is executed.
|
||||||
|
final result = value + 1;
|
||||||
|
|
||||||
|
// Then the assertion succeeds.
|
||||||
|
expect(result, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
const String _badTestFile = r'''
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Feature: Wrong', () {
|
||||||
|
test('Scenario: Wrong', () {
|
||||||
|
expect(1 + 1, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/src/gherkin_test_validator.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final validator = GherkinTestValidator();
|
||||||
|
final issues = validator.validateDirectory(Directory('test'));
|
||||||
|
|
||||||
|
if (issues.isEmpty) {
|
||||||
|
stdout.writeln('Gherkin test format validation passed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr.writeln('Gherkin test format validation failed:');
|
||||||
|
for (final issue in issues) {
|
||||||
|
stderr.writeln('- $issue');
|
||||||
|
}
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue