code_work_spawner/test/app_config_test.dart

943 lines
33 KiB
Dart

import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
String _resolveTestHomeDirectory() {
String? getEnv(String key) {
final direct = Platform.environment[key];
if (direct != null && direct.isNotEmpty) {
return direct;
}
for (final entry in Platform.environment.entries) {
if (entry.key.toLowerCase() == key.toLowerCase() &&
entry.value.isNotEmpty) {
return entry.value;
}
}
return null;
}
final home = getEnv('HOME');
if (home != null) {
return home;
}
final userProfile = getEnv('USERPROFILE');
if (userProfile != null) {
return userProfile;
}
final homeDrive = getEnv('HOMEDRIVE');
final homePath = getEnv('HOMEPATH');
if (homeDrive != null && homePath != null) {
return '$homeDrive$homePath';
}
throw StateError('No home directory environment variables are set.');
}
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', () {
/// ```gherkin
/// Scenario: Merge defaults with project overrides
/// Given camelCase 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 camelCase 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'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
prompt: "default {{repo}}"
commentPrefixTemplate: "> default prefix {{gh_login}}"
commentFooterTemplate: "default footer {{issue_number}}"
responders:
- id: fallback
command: responder
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
defaultBranch: main
issueAssistant:
prompt: "project {{project_key}}"
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps inherited defaults that were not overridden.
expect(config.projects['sample'], isNotNull);
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
expect(config.projects['sample']!.repoSlug.owner, 'owner');
expect(config.projects['sample']!.repoSlug.name, 'sample');
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@helper',
]);
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
/// Scenario: Use exported default automation comment templates
/// Given an orchestrator config that relies on default issue assistant comment templates
/// When loading the app config from disk
/// Then the assistant uses the exported default prefix and footer templates
/// ```
test('Use exported default automation comment templates', () async {
// Given an orchestrator config that relies on default issue assistant comment templates.
final tempDir = await Directory.systemTemp.createTemp(
'cws-default-templates-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the assistant uses the exported default prefix and footer templates.
expect(
config.projects['sample']!.issueAssistant.commentPrefixTemplate,
defaultCommentPrefixTemplate,
);
expect(
config.projects['sample']!.issueAssistant.commentFooterTemplate,
defaultCommentFooterTemplate,
);
expect(
(config.projects['sample']!.issueAssistant.eventSource
as PollingIssueEventSourceConfig)
.pollInterval,
const Duration(seconds: 30),
);
expect(
config.projects['sample']!.issueAssistant.eventSource,
isA<PollingIssueEventSourceConfig>(),
);
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3);
});
/// ```gherkin
/// Scenario: Resolve config placeholders from a colocated dotenv file
/// Given an orchestrator config with environment placeholders and a colocated dotenv file
/// When loading the app config from disk
/// Then placeholder values are resolved from the dotenv file
/// And later runtime lookups use the same loaded environment values
/// ```
test('Resolve config placeholders from a colocated dotenv file', () async {
// Given an orchestrator config with environment placeholders and a colocated dotenv file.
final tempDir = await Directory.systemTemp.createTemp('cws-dotenv-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
final dotenvFile = File(p.join(tempDir.path, '.env'));
addTearDown(AppEnvironment.resetForTest);
await dotenvFile.writeAsString('''
CWS_SAMPLE_WEBHOOK=https://dotenv.example.test/webhook
CWS_LOG_LEVEL=debug
''');
await configFile.writeAsString('''
defaults:
issueAssistant:
notifications:
- type: discordWebhook
webhookUrl: \${CWS_SAMPLE_WEBHOOK}
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then placeholder values are resolved from the dotenv file.
expect(
config
.projects['sample']!
.issueAssistant
.notifications
.single
.webhookUrl,
'https://dotenv.example.test/webhook',
);
// And later runtime lookups use the same loaded environment values.
expect(AppEnvironment.get('CWS_LOG_LEVEL'), 'debug');
});
/// ```gherkin
/// Scenario: Override visible comment templates per project
/// Given camelCase config with shared defaults and project-specific comment templates
/// When loading the app config from disk
/// Then the project-specific comment templates replace the shared defaults
/// ```
test('Override visible comment templates per project', () async {
// Given camelCase config with shared defaults and project-specific comment templates.
final tempDir = await Directory.systemTemp.createTemp(
'cws-comment-template-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
commentPrefixTemplate: "> default prefix"
commentFooterTemplate: "default footer"
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
commentPrefixTemplate: ""
commentFooterTemplate: "_project footer_"
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project-specific comment templates replace the shared defaults.
expect(
config.projects['sample']!.issueAssistant.commentPrefixTemplate,
'',
);
expect(
config.projects['sample']!.issueAssistant.commentFooterTemplate,
'_project footer_',
);
});
/// ```gherkin
/// Scenario: Normalize legacy codex json flag
/// Given an orchestrator config that still uses the legacy codex --json-output flag
/// When loading the app config from disk
/// Then the responder args are normalized to the current codex --json flag
/// ```
test('Normalize legacy codex json flag', () async {
// Given an orchestrator config that still uses the legacy codex --json-output flag.
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
responders:
- id: codex
command: codex
args: ["exec", "--json-output"]
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the responder args are normalized to the current codex --json flag.
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
'exec',
'--json',
]);
});
/// ```gherkin
/// Scenario: Merge notification defaults with project overrides
/// Given camelCase config with default notifications and a project-specific notification override
/// When loading the app config from disk
/// Then the project replaces inherited notifications with its own list
/// And notification enums are parsed from their config values
/// ```
test('Merge notification defaults with project overrides', () async {
// Given camelCase config with default notifications and a project-specific notification override.
final tempDir = await Directory.systemTemp.createTemp(
'cws-notification-config-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
notifications:
- type: discordWebhook
enabled: true
events: ["reply_posted"]
webhookUrl: https://default.example.test/webhook
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
notifications:
- type: discordWebhook
enabled: true
events: ["responder_failed", "no_reply"]
webhookUrl: https://project.example.test/webhook
username: cws
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project replaces inherited notifications with its own list.
expect(
config.projects['sample']!.issueAssistant.notifications,
hasLength(1),
);
final notification =
config.projects['sample']!.issueAssistant.notifications.single;
expect(notification.webhookUrl, 'https://project.example.test/webhook');
expect(notification.username, 'cws');
// And notification enums are parsed from their config values.
expect(notification.events, {
NotificationEvent.responderFailed,
NotificationEvent.noReply,
});
expect(notification.type, NotificationType.discordWebhook);
});
/// ```gherkin
/// Scenario: Load a desktop notifier from config
/// Given camelCase config with a desktop notifier
/// When loading the app config from disk
/// Then the notifier type is parsed from the config value
/// And the configured events are available to the app
/// ```
test('Load a desktop notifier from config', () async {
// Given camelCase config with a desktop notifier.
final tempDir = await Directory.systemTemp.createTemp(
'cws-desktop-notification-config-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
notifications:
- type: desktop
enabled: true
events: ["mention_detected", "reply_posted"]
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the notifier type is parsed from the config value.
final notification =
config.projects['sample']!.issueAssistant.notifications.single;
expect(notification.type, NotificationType.desktop);
// And the configured events are available to the app.
expect(notification.events, {
NotificationEvent.mentionDetected,
NotificationEvent.replyPosted,
});
});
/// ```gherkin
/// Scenario: Load camelCase config with shared worktree root
/// Given camelCase config with dataDir, worktreeDir, and project settings
/// When loading the app config from disk
/// Then the app exposes the configured data and worktree directories
/// And each project keeps the configured camelCase settings
/// ```
test('Load camelCase config with shared worktree root', () async {
// Given camelCase config with dataDir, worktreeDir, and project settings.
final tempDir = await Directory.systemTemp.createTemp('cws-camel-case-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
dataDir: ~/.agent-orchestrator
worktreeDir: ~/.worktrees
defaults:
issueAssistant:
enabled: false
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
defaultBranch: trunk
sessionPrefix: ao
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
final home = _resolveTestHomeDirectory();
// Then the app exposes the configured data and worktree directories.
expect(config.dataDir, p.join(home, '.agent-orchestrator'));
expect(config.worktreeDir, p.join(home, '.worktrees'));
// And each project keeps the configured camelCase settings.
expect(config.projects['sample'], isNotNull);
expect(config.projects['sample']!.defaultBranch, 'trunk');
expect(config.projects['sample']!.sessionPrefix, 'ao');
expect(config.projects['sample']!.issueAssistant.enabled, isFalse);
});
/// ```gherkin
/// Scenario: Load an explicit Gitea project provider
/// Given a config whose project sets provider to gitea
/// When loading the app config from disk
/// Then the project keeps the configured Gitea provider
/// And the repository slug still parses normally
/// ```
test('Load an explicit Gitea project provider', () async {
// Given a config whose project sets provider to gitea.
final tempDir = await Directory.systemTemp.createTemp('cws-gitea-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured Gitea provider.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
// And the repository slug still parses normally.
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
expect(
config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaPolling,
);
});
/// ```gherkin
/// Scenario: Load gh/gosmee as the issue event source
/// Given a GitHub project config that opts into gh/gosmee
/// When loading the app config from disk
/// Then the project keeps gh/gosmee as its event source
/// And the GitHub provider remains valid for that project
/// ```
test('Load gh/gosmee as the issue event source', () async {
// Given a GitHub project config that opts into gh/gosmee.
final tempDir = await Directory.systemTemp.createTemp('cws-gh-gosmee-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/gosmee
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps gh/gosmee as its event source.
expect(
config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghGosmee,
);
// And the GitHub provider remains valid for that project.
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
});
/// ```gherkin
/// Scenario: Reject gh/gosmee for non-GitHub providers
/// Given a Gitea project config that opts into gh/gosmee
/// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error
/// ```
test('Reject gh/gosmee for non-GitHub providers', () async {
// Given a Gitea project config that opts into gh/gosmee.
final tempDir = await Directory.systemTemp.createTemp(
'cws-gh-gosmee-gitea-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/gosmee
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('gh/gosmee requires provider: github'),
),
),
);
});
/// ```gherkin
/// Scenario: Load gh/webhook-forward as the issue event source
/// Given a GitHub project config that opts into gh/webhook-forward
/// When loading the app config from disk
/// Then the project keeps gh/webhook-forward as its event source
/// And the GitHub provider remains valid for that project
/// ```
test('Load gh/webhook-forward as the issue event source', () async {
// Given a GitHub project config that opts into gh/webhook-forward.
final tempDir = await Directory.systemTemp.createTemp(
'cws-webhook-forward-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps gh/webhook-forward as its event source.
expect(
config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghWebhookForward,
);
// And the GitHub provider remains valid for that project.
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
});
/// ```gherkin
/// Scenario: Load channel reconciliation settings
/// Given a GitHub project config that uses gh/webhook-forward reconciliation settings
/// When loading the app config from disk
/// Then the project keeps the configured reconciliation mode
/// And the configured reconcile interval is parsed for channel recovery
/// ```
test('Load channel reconciliation settings', () async {
// Given a GitHub project config that uses gh/webhook-forward reconciliation settings.
final tempDir = await Directory.systemTemp.createTemp(
'cws-channel-reconcile-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
reconciliation: startup-only
reconcileInterval: 5m
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured reconciliation mode.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconciliation,
IssueAssistantReconciliationKind.startupOnly,
);
// And the configured reconcile interval is parsed for channel recovery.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconcileInterval,
const Duration(minutes: 5),
);
});
/// ```gherkin
/// Scenario: Reuse pollInterval as the fallback reconcile interval
/// Given a legacy channel-based project config that still sets pollInterval
/// When loading the app config from disk
/// Then the project keeps the parsed poll interval for backward compatibility
/// And the same value is reused as the reconcile interval
/// ```
test('Reuse pollInterval as the fallback reconcile interval', () async {
// Given a legacy channel-based project config that still sets pollInterval.
final tempDir = await Directory.systemTemp.createTemp(
'cws-legacy-channel-poll-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
pollInterval: 45s
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the parsed poll interval for backward compatibility.
expect(
config.projects['sample']!.issueAssistant.eventSource,
isA<ChannelIssueEventSourceConfig>(),
);
// And the same value is reused as the reconcile interval.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconcileInterval,
const Duration(seconds: 45),
);
});
/// ```gherkin
/// Scenario: Reject gh/webhook-forward for non-GitHub providers
/// Given a Gitea project config that opts into gh/webhook-forward
/// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error
/// ```
test('Reject gh/webhook-forward for non-GitHub providers', () async {
// Given a Gitea project config that opts into gh/webhook-forward.
final tempDir = await Directory.systemTemp.createTemp(
'cws-webhook-forward-gitea-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('gh/webhook-forward requires provider: github'),
),
),
);
});
/// ```gherkin
/// Scenario: Load tea/gosmee as the issue event source
/// Given a Gitea project config that opts into tea/gosmee
/// When loading the app config from disk
/// Then the project keeps tea/gosmee as its event source
/// And the Gitea provider remains valid for that project
/// ```
test('Load tea/gosmee as the issue event source', () async {
// Given a Gitea project config that opts into tea/gosmee.
final tempDir = await Directory.systemTemp.createTemp('cws-gosmee-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: tea/gosmee
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps tea/gosmee as its event source.
expect(
config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaGosmee,
);
// And the Gitea provider remains valid for that project.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
});
/// ```gherkin
/// Scenario: Reject tea/gosmee for non-Gitea providers
/// Given a GitHub project config that opts into tea/gosmee
/// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error
/// ```
test('Reject tea/gosmee for non-Gitea providers', () async {
// Given a GitHub project config that opts into tea/gosmee.
final tempDir = await Directory.systemTemp.createTemp(
'cws-gosmee-github-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: tea/gosmee
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('tea/gosmee requires provider: gitea'),
),
),
);
});
/// ```gherkin
/// Scenario: Reject snake_case config keys
/// Given a config that still uses snake_case keys
/// When loading the app config from disk
/// Then loading fails with a clear config error
/// ```
test('Reject snake_case config keys', () async {
// Given a config that still uses snake_case keys.
final tempDir = await Directory.systemTemp.createTemp(
'cws-snake-case-invalid-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
worktree_dir: ~/.worktrees
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear config error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('worktree_dir'),
),
),
);
});
/// ```gherkin
/// Scenario: Reject malformed repository slugs
/// Given an orchestrator config whose project repo is not in owner/repo format
/// When loading the app config from disk
/// Then loading fails with a clear repository slug error
/// ```
test('Reject malformed repository slugs', () async {
// Given an orchestrator config whose project repo is not in owner/repo format.
final tempDir = await Directory.systemTemp.createTemp(
'cws-invalid-repo-slug-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
repo: owner
path: ${tempDir.path}
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear repository slug error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('Invalid repository slug for projects.sample.repo'),
),
),
);
});
/// ```gherkin
/// Scenario: Generate a starter config that parses as valid YAML
/// Given the built-in initial config renderer
/// When writing the generated starter config to disk
/// Then the file parses through the normal app config loader
/// And the required starter values stay available to users
/// ```
test('Generate a starter config that parses as valid YAML', () async {
// Given the built-in initial config renderer.
final tempDir = await Directory.systemTemp.createTemp('cws-init-config-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
final contents = AppConfig.renderInitialConfig();
// When writing the generated starter config to disk.
await configFile.writeAsString(contents);
final config = await AppConfig.load(configFile.path);
final home = _resolveTestHomeDirectory();
// Then the file parses through the normal app config loader.
expect(config.projects['sample'], isNotNull);
// And the required starter values stay available to users.
expect(config.dataDir, p.join(home, '.agent-orchestrator'));
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
expect(config.worktreeDir, p.join(home, '.worktrees'));
expect(config.projects['sample']!.repo, 'owner/repo');
expect(
config.projects['sample']!.path,
p.join(home, 'path', 'to', 'repo'),
);
expect(config.projects['sample']!.defaultBranch, 'main');
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@codex',
'@helper',
]);
});
/// ```gherkin
/// Scenario: Parse uncommented optional starter fields
/// Given a generated starter config with optional example lines uncommented
/// When loading that config through the normal parser
/// Then the uncommented optional values are accepted
/// And the parsed config reflects the example optional settings
/// ```
test('Parse uncommented optional starter fields', () async {
// Given a generated starter config with optional example lines uncommented.
final tempDir = await Directory.systemTemp.createTemp(
'cws-init-optional-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
final contents = AppConfig.renderInitialConfig()
.replaceFirst(
' # capabilities: ["planning", "bug_verification"]',
' capabilities: ["planning", "bug_verification"]',
)
.replaceFirst(
' # commentTag: code-work-spawner',
' commentTag: code-work-spawner',
)
.replaceFirst(' # responders:', ' responders:')
.replaceFirst(' # - id: codex', ' - id: codex')
.replaceFirst(' # command: codex', ' command: codex')
.replaceFirst(
' # args: ["exec", "--json"]',
' args: ["exec", "--json"]',
)
.replaceFirst(' # timeout: 10m', ' timeout: 10m')
.replaceFirst(' # issueAssistant:', ' issueAssistant:')
.replaceFirst(' # enabled: true', ' enabled: true')
.replaceFirst(' # eventSource:', ' eventSource:')
.replaceFirst(' # type: gh/gosmee', ' type: gh/gosmee')
.replaceFirst(
' # reconciliation: startup-only',
' reconciliation: startup-only',
)
.replaceFirst(
' # reconcileInterval: 30s',
' reconcileInterval: 30s',
)
.replaceFirst(
' # mentionTriggers: ["@helper"]',
' mentionTriggers: ["@helper"]',
)
.replaceFirst(
' # sessionPrefix: sample',
' sessionPrefix: sample',
);
await configFile.writeAsString(contents);
// When loading that config through the normal parser.
final config = await AppConfig.load(configFile.path);
// Then the uncommented optional values are accepted.
expect(config.projects['sample'], isNotNull);
// And the parsed config reflects the example optional settings.
expect(config.defaults.commentTag, 'code-work-spawner');
expect(config.defaults.responders.single.id, 'codex');
expect(
config.defaults.capabilities,
containsAll({
AssistantCapability.planning,
AssistantCapability.bugVerification,
}),
);
expect(config.projects['sample']!.sessionPrefix, 'sample');
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@helper',
]);
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconciliation,
IssueAssistantReconciliationKind.startupOnly,
);
});
});
}