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
|
||||
.code_work_spawner.sqlite3
|
||||
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
|
||||
useful
|
||||
|
||||
It supports multi-repo configuration, responder fallback chains, Drift-backed
|
||||
SQLite state, and BDD-style Dart tests.
|
||||
It supports multi-repo configuration, responder fallback chains.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -93,10 +92,18 @@ dart run bin/code_work_spawner.dart \
|
|||
## Testing
|
||||
|
||||
```bash
|
||||
dart test
|
||||
dart run tool/validate_gherkin_test_format.dart
|
||||
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
|
||||
|
||||
[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() {
|
||||
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', () {
|
||||
test(
|
||||
'given AO style config when loading then defaults and project overrides are merged',
|
||||
() async {
|
||||
// Given a config file with shared defaults and a project override.
|
||||
/// ```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 {
|
||||
// Given AO style config with shared defaults and one project override.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
||||
final configFile = File(
|
||||
p.join(tempDir.path, 'agent-orchestrator.yaml'),
|
||||
);
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
defaults:
|
||||
issueAssistant:
|
||||
|
|
@ -37,35 +47,51 @@ projects:
|
|||
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);
|
||||
|
||||
// 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']!.issueAssistant.mentionTriggers, [
|
||||
'@helper',
|
||||
]);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.prompt,
|
||||
'project {{project_key}}',
|
||||
);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.responders.single.id,
|
||||
'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', () {
|
||||
test(
|
||||
'given an explicit mention when polling then the app posts the responder reply once',
|
||||
() async {
|
||||
// Given a fake gh client and a fake responder that emits a planning reply.
|
||||
/// ```gherkin
|
||||
/// Scenario: Reply once when a user explicitly mentions the assistant
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// 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 repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
await _initGitRepo(repoDir);
|
||||
|
||||
// And GitHub issue data containing a comment with an explicit assistant mention.
|
||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||
final issueData = [
|
||||
|
|
@ -92,51 +118,25 @@ projects:
|
|||
'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',
|
||||
'mode': 'planning',
|
||||
'markdown': 'Plan:\n- separate service and persistence',
|
||||
'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'));
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
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'),
|
||||
);
|
||||
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
defaults:
|
||||
issueAssistant:
|
||||
|
|
@ -153,40 +153,47 @@ projects:
|
|||
path: ${repoDir.path}
|
||||
defaultBranch: main
|
||||
''');
|
||||
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: config,
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
);
|
||||
|
||||
// When the app processes a single polling cycle.
|
||||
// When the app processes one polling cycle.
|
||||
await app.runOnce();
|
||||
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();
|
||||
expect(
|
||||
logLines
|
||||
.where(
|
||||
(line) =>
|
||||
line.contains('repos/owner/sample/issues/12/comments'),
|
||||
(line) => line.contains('repos/owner/sample/issues/12/comments'),
|
||||
)
|
||||
.length,
|
||||
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(
|
||||
'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 {
|
||||
// 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 repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
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 issueData = [
|
||||
{
|
||||
|
|
@ -210,40 +217,21 @@ projects:
|
|||
'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',
|
||||
'mode': 'planning',
|
||||
'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(
|
||||
p.join(sandbox.path, 'agent-orchestrator.yaml'),
|
||||
);
|
||||
|
|
@ -261,27 +249,110 @@ projects:
|
|||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
''');
|
||||
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
);
|
||||
|
||||
// When a poll cycle evaluates the unchanged discussion.
|
||||
// When the app processes one polling cycle.
|
||||
await app.runOnce();
|
||||
final thread = await app.database.findIssueThread('sample', 42);
|
||||
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!.lastDecision, 'no_reply');
|
||||
|
||||
// And it does not store a posted GitHub comment for that thread version.
|
||||
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 {
|
||||
await Process.run('git', ['init'], workingDirectory: directory.path);
|
||||
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