feat: add Gherkin test validation and enforce doc-comment format

This commit is contained in:
insleker 2026-04-04 19:54:24 +08:00
parent da87c670ae
commit aa8d122ab9
11 changed files with 871 additions and 163 deletions

24
.github/workflows/unittest.yaml vendored Normal file
View File

@ -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

7
.gitignore vendored
View File

@ -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

4
AGENTS.md Normal file
View File

@ -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`.

View File

@ -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)

8
lefthook.yml Normal file
View File

@ -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

View File

@ -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;
}
}

90
skills-lock.json Normal file
View File

@ -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"
}
}
}

View File

@ -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`.

View File

@ -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', [

View File

@ -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);
});
});
}
''';

View File

@ -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;
}