From a8d587313261a04fb46d5e1c2e5f387f64fe1f74 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Wed, 4 Mar 2026 12:54:07 -0500 Subject: [PATCH 1/3] feat: address open issue backlog in CI tooling Add configurable update diffs and git org rewrites while making documentation/autodoc flows sub-package aware so multi-package repos generate clearer, more reliable outputs. Expand validation and command tests to cover timeout/failure edge paths and reduce regression risk in managed CI workflows. --- .runtime_ci/config.json | 1 + lib/src/cli/commands/autodoc_command.dart | 171 +++++++++++++++++- .../cli/commands/documentation_command.dart | 11 ++ lib/src/cli/commands/update_command.dart | 99 +++++++++- lib/src/cli/options/update_all_options.g.dart | 7 +- lib/src/cli/options/update_options.dart | 5 + lib/src/cli/options/update_options.g.dart | 4 +- lib/src/cli/utils/sub_package_utils.dart | 84 +++++++++ lib/src/cli/utils/workflow_generator.dart | 57 ++++++ templates/config.json | 2 + templates/github/workflows/ci.skeleton.yaml | 20 +- test/cli_utils_test.dart | 47 +++++ test/test_command_test.dart | 109 +++++++++++ test/workflow_generator_test.dart | 53 ++++++ 14 files changed, 647 insertions(+), 23 deletions(-) diff --git a/.runtime_ci/config.json b/.runtime_ci/config.json index 426959a..ee6e539 100644 --- a/.runtime_ci/config.json +++ b/.runtime_ci/config.json @@ -68,6 +68,7 @@ "line_length": 120, "artifact_retention_days": 7, "personal_access_token_secret": "TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN", + "git_orgs": ["open-runtime", "pieces-app"], "features": { "proto": false, "lfs": false, diff --git a/lib/src/cli/commands/autodoc_command.dart b/lib/src/cli/commands/autodoc_command.dart index 492810c..5cd9cc0 100644 --- a/lib/src/cli/commands/autodoc_command.dart +++ b/lib/src/cli/commands/autodoc_command.dart @@ -16,6 +16,8 @@ import '../utils/logger.dart'; import '../utils/process_runner.dart'; import '../utils/repo_utils.dart'; import '../utils/step_summary.dart'; +import '../utils/sub_package_utils.dart'; +import '../utils/version_detection.dart'; const String _kGeminiProModel = 'gemini-3.1-pro-preview'; @@ -96,6 +98,24 @@ class AutodocCommand extends Command { return; } + final subPackages = SubPackageUtils.loadSubPackages(repoRoot); + SubPackageUtils.logSubPackages(subPackages); + var subPackageDiffContext = ''; + if (subPackages.isNotEmpty) { + String prevTag; + try { + prevTag = VersionDetection.detectPrevTag(repoRoot, verbose: global.verbose); + } catch (_) { + prevTag = ''; + } + subPackageDiffContext = SubPackageUtils.buildSubPackageDiffContext( + repoRoot: repoRoot, + prevTag: prevTag, + subPackages: subPackages, + verbose: global.verbose, + ); + } + // Build task queue based on hash comparison final tasks = Function()>[]; final updatedModules = []; @@ -106,7 +126,30 @@ class AutodocCommand extends Command { if (targetModule != null && id != targetModule) continue; final sourcePaths = (module['source_paths'] as List).cast(); - final currentHash = _computeModuleHash(repoRoot, sourcePaths); + final moduleSubPackage = _resolveModuleSubPackage( + module: module, + sourcePaths: sourcePaths, + subPackages: subPackages, + ); + if (moduleSubPackage != null && module['sub_package'] == null) { + module['sub_package'] = moduleSubPackage; + } + + final configuredOutputPath = module['output_path'] as String; + final normalizedOutputPath = _resolveOutputPathForModule( + configuredOutputPath: configuredOutputPath, + moduleSubPackage: moduleSubPackage, + ); + if (normalizedOutputPath != configuredOutputPath) { + module['output_path'] = normalizedOutputPath; + } + + final currentHash = _computeModuleHash( + repoRoot, + sourcePaths, + moduleSubPackage: moduleSubPackage, + outputPath: normalizedOutputPath, + ); final previousHash = module['hash'] as String? ?? ''; if (currentHash == previousHash && !force) { @@ -116,12 +159,15 @@ class AutodocCommand extends Command { } final name = module['name'] as String; - final outputPath = '$repoRoot/${module['output_path']}'; + final outputPath = '$repoRoot/$normalizedOutputPath'; final libPaths = (module['lib_paths'] as List?)?.cast() ?? []; final generateTypes = (module['generate'] as List).cast(); final libDir = libPaths.isNotEmpty ? '$repoRoot/${libPaths.first}' : ''; + final packageSuffix = moduleSubPackage == null ? '' : ' [sub-package: $moduleSubPackage]'; - Logger.info(' $id ($name): ${force ? "forced" : "changed"} -> generating ${generateTypes.join(", ")}'); + Logger.info( + ' $id ($name)$packageSuffix: ${force ? "forced" : "changed"} -> generating ${generateTypes.join(", ")}', + ); if (dryRun) { updatedModules.add(id); @@ -151,6 +197,13 @@ class AutodocCommand extends Command { libDir: libDir, outputPath: outputPath, previousHash: previousHash, + moduleSubPackage: moduleSubPackage, + subPackageDiffContext: subPackageDiffContext, + hierarchicalAutodocInstructions: SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: name, + subPackages: subPackages, + moduleSubPackage: moduleSubPackage, + ), verbose: global.verbose, ), ); @@ -184,6 +237,10 @@ class AutodocCommand extends Command { await _forEachConcurrent(tasks, maxConcurrent); + if (subPackages.isNotEmpty) { + _writeHierarchicalDocsIndex(repoRoot: repoRoot, modules: modules, subPackages: subPackages); + } + // Save updated config with new hashes File(configPath).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(autodocConfig)); @@ -220,6 +277,9 @@ ${StepSummary.artifactLink()} required String libDir, required String outputPath, required String previousHash, + required String? moduleSubPackage, + required String subPackageDiffContext, + required String hierarchicalAutodocInstructions, bool verbose = false, }) async { final outputFileName = switch (docType) { @@ -266,6 +326,10 @@ ${StepSummary.artifactLink()} pass1Prompt.writeAsStringSync(''' $prompt +${moduleSubPackage == null ? '' : 'This module belongs to sub-package: "$moduleSubPackage".'} +${subPackageDiffContext.isEmpty ? '' : subPackageDiffContext} +${hierarchicalAutodocInstructions.isEmpty ? '' : hierarchicalAutodocInstructions} + ## OUTPUT INSTRUCTIONS Write the generated documentation to this exact file path using write_file: @@ -321,6 +385,9 @@ Your task is to review and improve the file at: This documentation was auto-generated for the **$moduleName** module. The proto definitions are in: ${sourceDir.replaceFirst('$repoRoot/', '')} ${libDir.isNotEmpty ? 'Generated Dart code is in: ${libDir.replaceFirst('$repoRoot/', '')}' : ''} +${moduleSubPackage == null ? '' : 'This module belongs to sub-package: "$moduleSubPackage".'} +${subPackageDiffContext.isEmpty ? '' : subPackageDiffContext} +${hierarchicalAutodocInstructions.isEmpty ? '' : hierarchicalAutodocInstructions} ## Review Checklist @@ -457,7 +524,7 @@ Write the corrected file to the same path: $absOutputFile } /// Compute SHA256 hash of all source files in the given paths. - String _computeModuleHash(String repoRoot, List sourcePaths) { + String _computeModuleHash(String repoRoot, List sourcePaths, {String? moduleSubPackage, String? outputPath}) { // Pure-Dart hashing so caching works on macOS/Windows and minimal CI images // (no dependency on sha256sum/shasum/xargs/find). final filePaths = []; @@ -485,6 +552,15 @@ Write the corrected file to the same path: $absOutputFile final builder = BytesBuilder(copy: false); + if (moduleSubPackage != null) { + builder.add(utf8.encode('sub_package:$moduleSubPackage')); + builder.addByte(0); + } + if (outputPath != null) { + builder.add(utf8.encode('output_path:$outputPath')); + builder.addByte(0); + } + for (final path in filePaths) { // Include the path name in the digest so renames affect the hash. builder.add(utf8.encode(path)); @@ -503,4 +579,91 @@ Write the corrected file to the same path: $absOutputFile return sha256.convert(builder.takeBytes()).toString(); } + + String? _resolveModuleSubPackage({ + required Map module, + required List sourcePaths, + required List> subPackages, + }) { + if (subPackages.isEmpty) return null; + + final explicit = module['sub_package']; + if (explicit is String && explicit.trim().isNotEmpty) { + final explicitName = explicit.trim(); + final exists = subPackages.any((pkg) => (pkg['name'] as String) == explicitName); + if (exists) return explicitName; + } + + final normalizedSources = sourcePaths.map((s) => p.posix.normalize(s).replaceFirst(RegExp(r'^/+'), '')).toList(); + for (final pkg in subPackages) { + final packagePath = p.posix.normalize(pkg['path'] as String).replaceFirst(RegExp(r'^/+'), ''); + final packagePrefix = packagePath.endsWith('/') ? packagePath : '$packagePath/'; + if (normalizedSources.any((src) => src.startsWith(packagePrefix))) { + return pkg['name'] as String; + } + } + return null; + } + + String _resolveOutputPathForModule({required String configuredOutputPath, required String? moduleSubPackage}) { + final normalized = p.posix.normalize(configuredOutputPath).replaceFirst(RegExp(r'^/+'), ''); + if (moduleSubPackage == null || moduleSubPackage.isEmpty) { + return normalized; + } + + final docsPrefix = 'docs/'; + final stripped = normalized.startsWith(docsPrefix) ? normalized.substring(docsPrefix.length) : normalized; + if (stripped.startsWith('$moduleSubPackage/')) { + return '$docsPrefix$stripped'; + } + return '$docsPrefix$moduleSubPackage/$stripped'; + } + + void _writeHierarchicalDocsIndex({ + required String repoRoot, + required List> modules, + required List> subPackages, + }) { + if (subPackages.isEmpty) return; + + final grouped = >>{}; + for (final pkg in subPackages) { + grouped[pkg['name'] as String] = >[]; + } + + for (final module in modules) { + final sourcePaths = (module['source_paths'] as List).cast(); + final resolvedPackage = _resolveModuleSubPackage( + module: module, + sourcePaths: sourcePaths, + subPackages: subPackages, + ); + if (resolvedPackage == null) continue; + grouped.putIfAbsent(resolvedPackage, () => >[]).add(module); + } + + final buf = StringBuffer() + ..writeln('# Documentation Index') + ..writeln() + ..writeln('Generated by `manage_cicd autodoc` for multi-package repositories.') + ..writeln(); + + for (final pkg in subPackages) { + final packageName = pkg['name'] as String; + final packageModules = grouped[packageName] ?? >[]; + if (packageModules.isEmpty) continue; + buf.writeln('## $packageName'); + buf.writeln(); + for (final module in packageModules) { + final moduleName = module['name'] as String; + final outputPath = (module['output_path'] as String).replaceFirst(RegExp(r'^docs/'), ''); + buf.writeln('- [$moduleName](./$outputPath)'); + } + buf.writeln(); + } + + final docsDir = Directory('$repoRoot/docs')..createSync(recursive: true); + File('${docsDir.path}/README.md').writeAsStringSync(buf.toString()); + Logger.info('Updated hierarchical docs index: docs/README.md'); + } } diff --git a/lib/src/cli/commands/documentation_command.dart b/lib/src/cli/commands/documentation_command.dart index 3d562eb..cf6bbcd 100644 --- a/lib/src/cli/commands/documentation_command.dart +++ b/lib/src/cli/commands/documentation_command.dart @@ -13,6 +13,7 @@ import '../utils/logger.dart'; import '../utils/process_runner.dart'; import '../utils/prompt_resolver.dart'; import '../utils/repo_utils.dart'; +import '../utils/sub_package_utils.dart'; import '../utils/version_detection.dart'; const String _kGeminiProModel = 'gemini-3.1-pro-preview'; @@ -69,6 +70,16 @@ class DocumentationCommand extends Command { } ctx.savePrompt('documentation', prompt); + // Enrich prompt with sub-package context for multi-package repos. + SubPackageUtils.enrichPromptWithSubPackages( + repoRoot: repoRoot, + prevTag: prevTag, + promptFilePath: ctx.artifactPath('documentation', 'prompt.txt'), + buildInstructions: SubPackageUtils.buildHierarchicalDocumentationInstructions, + newVersion: newVersion, + verbose: global.verbose, + ); + if (global.dryRun) { Logger.info('[DRY-RUN] Would run Gemini for documentation update (${prompt.length} chars)'); return; diff --git a/lib/src/cli/commands/update_command.dart b/lib/src/cli/commands/update_command.dart index f91f052..a9c377a 100644 --- a/lib/src/cli/commands/update_command.dart +++ b/lib/src/cli/commands/update_command.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; import '../../triage/utils/config.dart'; import '../../triage/utils/run_context.dart'; @@ -46,6 +47,7 @@ class UpdateCommand extends Command { final dryRun = global.dryRun; final verbose = global.verbose; final force = opts.force; + final diff = opts.diff; Logger.header('Update Runtime CI Tooling Templates'); @@ -99,6 +101,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'cautious' => _processCautious( repoRoot, @@ -110,6 +113,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'templated' => _processTemplated( repoRoot, @@ -120,6 +124,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'mergeable' => _processMergeable( repoRoot, @@ -130,8 +135,9 @@ class UpdateCommand extends Command { force: force, dryRun: dryRun, verbose: verbose, + diff: diff, ), - 'regeneratable' => _processRegeneratable(repoRoot, entry, dryRun: dryRun, verbose: verbose), + 'regeneratable' => _processRegeneratable(repoRoot, entry, dryRun: dryRun, verbose: verbose, diff: diff), _ => _UpdateResult(entry.id, 'skipped', reason: 'unknown category: ${entry.category}'), }; @@ -203,10 +209,12 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { final templatePath = '$templatesDir/${entry.source}'; final destPath = '$repoRoot/${entry.destination}'; final templateHash = computeFileHash(templatePath); + final templateContent = File(templatePath).readAsStringSync(); final installedHash = tracker.getInstalledHash(entry.id); if (templateHash == installedHash && !force) { @@ -216,9 +224,12 @@ class UpdateCommand extends Command { final destFile = File(destPath); if (!destFile.existsSync()) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: '', after: templateContent); + } if (!dryRun) { Directory(destFile.parent.path).createSync(recursive: true); - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -240,12 +251,15 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'warning', reason: 'local customizations -- use --force'); } + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } if (!dryRun) { if (backup) { destFile.copySync('$destPath.bak'); Logger.info(' ${entry.id}: backed up to ${entry.destination}.bak'); } - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -267,10 +281,12 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { final templatePath = '$templatesDir/${entry.source}'; final destPath = '$repoRoot/${entry.destination}'; final templateHash = computeFileHash(templatePath); + final templateContent = File(templatePath).readAsStringSync(); final installedHash = tracker.getInstalledHash(entry.id); if (templateHash == installedHash && !force) { @@ -280,9 +296,12 @@ class UpdateCommand extends Command { final destFile = File(destPath); if (!destFile.existsSync()) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: '', after: templateContent); + } if (!dryRun) { Directory(destFile.parent.path).createSync(recursive: true); - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -300,16 +319,22 @@ class UpdateCommand extends Command { 'Review manually or use --force to overwrite: ${entry.destination}', ); Logger.info(' Compare with: $templatePath'); + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } return _UpdateResult(entry.id, 'warning', reason: 'template changed -- review or use --force'); } // --force: overwrite + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } if (!dryRun) { if (backup) { destFile.copySync('$destPath.bak'); Logger.info(' ${entry.id}: backed up to ${entry.destination}.bak'); } - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -330,6 +355,7 @@ class UpdateCommand extends Command { required bool force, required bool dryRun, required bool verbose, + required bool diff, }) { final destPath = '$repoRoot/${entry.destination}'; final destFile = File(destPath); @@ -346,8 +372,9 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'no reference template'); } + final originalContent = destFile.readAsStringSync(); final refConfig = json.decode(refFile.readAsStringSync()) as Map; - final consumerConfig = json.decode(destFile.readAsStringSync()) as Map; + final consumerConfig = json.decode(originalContent) as Map; final addedKeys = []; _deepMergeNewKeys(refConfig, consumerConfig, addedKeys, prefix: ''); @@ -362,8 +389,12 @@ class UpdateCommand extends Command { Logger.info(' + $key'); } + final mergedContent = '${const JsonEncoder.withIndent(' ').convert(consumerConfig)}\n'; + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: originalContent, after: mergedContent); + } if (!dryRun) { - destFile.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(consumerConfig)}\n'); + destFile.writeAsStringSync(mergedContent); tracker.recordUpdate( entry.id, templateHash: computeFileHash(refPath), @@ -381,6 +412,7 @@ class UpdateCommand extends Command { TemplateEntry entry, { required bool dryRun, required bool verbose, + required bool diff, }) { final destPath = '$repoRoot/${entry.destination}'; final destFile = File(destPath); @@ -390,7 +422,8 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'warning', reason: 'file missing -- run init'); } - final autodocConfig = json.decode(destFile.readAsStringSync()) as Map; + final originalContent = destFile.readAsStringSync(); + final autodocConfig = json.decode(originalContent) as Map; final existingModules = (autodocConfig['modules'] as List).cast>(); final existingIds = existingModules.map((m) => m['id'] as String).toSet(); @@ -427,8 +460,12 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'no new modules'); } + final regeneratedContent = '${const JsonEncoder.withIndent(' ').convert(autodocConfig)}\n'; + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: originalContent, after: regeneratedContent); + } if (!dryRun) { - destFile.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(autodocConfig)}\n'); + destFile.writeAsStringSync(regeneratedContent); } Logger.success(' ${entry.id}: added $newModuleCount new module(s)'); @@ -444,6 +481,7 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { // Templated entries require a source skeleton if (entry.source == null) { @@ -492,6 +530,9 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'output unchanged'); } + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: existingContent ?? '', after: rendered); + } if (!dryRun) { if (backup && destFile.existsSync()) { destFile.copySync('$destPath.bak'); @@ -518,6 +559,46 @@ class UpdateCommand extends Command { // Utilities // ═══════════════════════════════════════════════════════════════════════════ + void _emitUnifiedDiff({required String destination, required String before, required String after}) { + if (before == after) return; + Directory? tempDir; + try { + tempDir = Directory.systemTemp.createTempSync('runtime_ci_diff_'); + final beforeFile = File(p.join(tempDir.path, 'before.txt'))..writeAsStringSync(before); + final afterFile = File(p.join(tempDir.path, 'after.txt'))..writeAsStringSync(after); + + final result = Process.runSync('git', [ + '--no-pager', + 'diff', + '--no-index', + '--', + beforeFile.path, + afterFile.path, + ]); + var diffText = '${result.stdout ?? ''}${result.stderr ?? ''}'.trimRight(); + if (diffText.isEmpty) { + Logger.info(' [diff] ${entryPath(destination)} changed'); + return; + } + + final normalizedPath = entryPath(destination); + diffText = diffText.replaceAll('a${beforeFile.path}', 'a/$normalizedPath'); + diffText = diffText.replaceAll('b${afterFile.path}', 'b/$normalizedPath'); + diffText = diffText.replaceAll(beforeFile.path, 'a/$normalizedPath'); + diffText = diffText.replaceAll(afterFile.path, 'b/$normalizedPath'); + Logger.info(' [diff] ${entryPath(destination)}'); + Logger.info(diffText); + } catch (e) { + Logger.warn(' [diff] Could not render unified diff for ${entryPath(destination)}: $e'); + } finally { + if (tempDir != null && tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } + } + + String entryPath(String destination) => destination.replaceAll(RegExp(r'^/+'), ''); + /// Recursively merge keys from [source] into [target] that don't exist in /// target. Records added key paths to [addedKeys]. void _deepMergeNewKeys( diff --git a/lib/src/cli/options/update_all_options.g.dart b/lib/src/cli/options/update_all_options.g.dart index 5271a54..d95617e 100644 --- a/lib/src/cli/options/update_all_options.g.dart +++ b/lib/src/cli/options/update_all_options.g.dart @@ -6,9 +6,14 @@ part of 'update_all_options.dart'; // CliGenerator // ************************************************************************** +T _$badNumberFormat(String source, String type, String argName) => + throw FormatException('Cannot parse "$source" into `$type` for option "$argName".'); + UpdateAllOptions _$parseUpdateAllOptionsResult(ArgResults result) => UpdateAllOptions( scanRoot: result['scan-root'] as String?, - concurrency: int.parse(result['concurrency'] as String), + concurrency: + int.tryParse(result['concurrency'] as String) ?? + _$badNumberFormat(result['concurrency'] as String, 'int', 'concurrency'), force: result['force'] as bool, workflows: result['workflows'] as bool, templates: result['templates'] as bool, diff --git a/lib/src/cli/options/update_options.dart b/lib/src/cli/options/update_options.dart index 54ba1e5..0782034 100644 --- a/lib/src/cli/options/update_options.dart +++ b/lib/src/cli/options/update_options.dart @@ -29,6 +29,10 @@ class UpdateOptions { @CliOption(help: 'Write .bak backup before overwriting files') final bool backup; + /// Show a unified diff preview for each file that would be updated. + @CliOption(help: 'Show unified diffs for updated files') + final bool diff; + const UpdateOptions({ this.force = false, this.templates = false, @@ -36,6 +40,7 @@ class UpdateOptions { this.workflows = false, this.autodoc = false, this.backup = false, + this.diff = false, }); factory UpdateOptions.fromArgResults(ArgResults results) { diff --git a/lib/src/cli/options/update_options.g.dart b/lib/src/cli/options/update_options.g.dart index 8738685..791b169 100644 --- a/lib/src/cli/options/update_options.g.dart +++ b/lib/src/cli/options/update_options.g.dart @@ -13,6 +13,7 @@ UpdateOptions _$parseUpdateOptionsResult(ArgResults result) => UpdateOptions( workflows: result['workflows'] as bool, autodoc: result['autodoc'] as bool, backup: result['backup'] as bool, + diff: result['diff'] as bool, ); ArgParser _$populateUpdateOptionsParser(ArgParser parser) => parser @@ -21,7 +22,8 @@ ArgParser _$populateUpdateOptionsParser(ArgParser parser) => parser ..addFlag('config', help: 'Only merge new keys into .runtime_ci/config.json') ..addFlag('workflows', help: 'Only update GitHub workflow files (.github/workflows/)') ..addFlag('autodoc', help: 'Re-scan lib/src/ and update autodoc.json modules') - ..addFlag('backup', help: 'Write .bak backup before overwriting files'); + ..addFlag('backup', help: 'Write .bak backup before overwriting files') + ..addFlag('diff', help: 'Show unified diffs for updated files'); final _$parserForUpdateOptions = _$populateUpdateOptionsParser(ArgParser()); diff --git a/lib/src/cli/utils/sub_package_utils.dart b/lib/src/cli/utils/sub_package_utils.dart index 9a7d810..eb82ba4 100644 --- a/lib/src/cli/utils/sub_package_utils.dart +++ b/lib/src/cli/utils/sub_package_utils.dart @@ -250,6 +250,90 @@ Rules: '''; } + /// Build hierarchical documentation prompt instructions for multi-package repos. + /// + /// Used by the `documentation` command to ensure root README updates contain + /// both a repo-level narrative and clear per-package sections. + static String buildHierarchicalDocumentationInstructions({ + required String newVersion, + required List> subPackages, + }) { + if (subPackages.isEmpty) return ''; + final packageNames = subPackages.map((p) => p['name']).join(', '); + + final packageExamples = StringBuffer(); + for (final pkg in subPackages) { + packageExamples.writeln('### ${pkg['name']}'); + packageExamples.writeln('- Purpose: ...'); + packageExamples.writeln('- Key APIs / entry points: ...'); + packageExamples.writeln('- Notable changes in this release: ...'); + packageExamples.writeln(); + } + + return ''' + +## Hierarchical Documentation Format (Multi-Package) + +Because this is a multi-package repository ($packageNames), the updated +documentation MUST be hierarchical: + +1. A top-level overview that explains the full repository and how packages fit + together. +2. A dedicated section for each changed sub-package. +3. Explicit cross-links between root documentation and package-specific docs. + +Recommended structure: +```markdown +# + +## Overview +... + +## Package Architecture +... + +${packageExamples.toString().trimRight()} +``` + +Rules: +- Only use valid package names from: $packageNames +- Do not invent additional sub-packages +- Keep root docs coherent across all packages +- Mention package relationships where relevant +'''; + } + + /// Build hierarchical autodoc instructions for one module in a multi-package repo. + /// + /// [moduleSubPackage] is optional. When available, the generated docs should + /// explicitly identify the owning package and describe how this module + /// relates to sibling packages. + static String buildHierarchicalAutodocInstructions({ + required String moduleName, + required List> subPackages, + String? moduleSubPackage, + }) { + if (subPackages.isEmpty) return ''; + final packageNames = subPackages.map((p) => p['name']).join(', '); + final owningPackage = moduleSubPackage == null ? 'not explicitly assigned' : '"$moduleSubPackage"'; + + return ''' + +## Multi-Package Autodoc Context + +This repository is multi-package ($packageNames). + +Current module: "$moduleName" +Owning package: $owningPackage + +Documentation requirements: +- Call out which package owns this module. +- Describe how this module integrates with other packages when applicable. +- Use package-aware wording (avoid implying this module is the entire repo). +- Keep references and examples aligned with the module's owning package. +'''; + } + /// Enrich an existing prompt file with sub-package diff context and /// hierarchical formatting instructions. /// diff --git a/lib/src/cli/utils/workflow_generator.dart b/lib/src/cli/utils/workflow_generator.dart index 2b1f5b5..6729dbc 100644 --- a/lib/src/cli/utils/workflow_generator.dart +++ b/lib/src/cli/utils/workflow_generator.dart @@ -71,6 +71,14 @@ bool _isSafeRunnerLabel(String s) { return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); } +/// GitHub org segment used in git URL rewrites (e.g. open-runtime). +/// +/// Keep this conservative: no slashes, no whitespace, no control chars. +bool _isSafeGitOrgSegment(String s) { + if (s.contains(RegExp(r'[\r\n\t\x00-\x1f]'))) return false; + return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); +} + /// Sub-package names are rendered into YAML and shell-facing messages. /// Keep them to a conservative character set. bool _isSafeSubPackageName(String s) { @@ -192,6 +200,7 @@ class WorkflowGenerator { final secretsRaw = ciConfig['secrets']; final secrets = secretsRaw is Map ? secretsRaw : {}; final subPackages = ciConfig['sub_packages'] as List? ?? []; + final gitOrgs = _resolveGitOrgs(ciConfig); // Build secrets list for env block (skip non-string values) final secretsList = >[]; @@ -261,6 +270,7 @@ class WorkflowGenerator { // Secrets / env 'has_secrets': secretsList.isNotEmpty, 'secrets_list': secretsList, + 'git_orgs': gitOrgs.map((org) => {'org': org}).toList(), // Sub-packages (filter out invalid entries) 'sub_packages': subPackages @@ -298,6 +308,25 @@ class WorkflowGenerator { return '120'; } + static List _resolveGitOrgs(Map ciConfig) { + final raw = ciConfig['git_orgs']; + final resolved = []; + if (raw is List) { + for (final value in raw) { + if (value is! String) continue; + final org = value.trim(); + if (org.isEmpty) continue; + if (!_isSafeGitOrgSegment(org)) continue; + if (resolved.contains(org)) continue; + resolved.add(org); + } + } + if (resolved.isEmpty) { + return const ['open-runtime', 'pieces-app']; + } + return resolved; + } + static String _resolveArtifactRetentionDays(dynamic raw) { if (raw is int && raw >= 1 && raw <= 90) return '$raw'; if (raw is String) { @@ -446,6 +475,34 @@ class WorkflowGenerator { } else if (pat is String && !_isSafeSecretIdentifier(pat)) { errors.add('ci.personal_access_token_secret "$pat" must be a safe identifier (e.g. GITHUB_TOKEN)'); } + final gitOrgs = ciConfig['git_orgs']; + if (gitOrgs != null) { + if (gitOrgs is! List) { + errors.add('ci.git_orgs must be an array, got ${gitOrgs.runtimeType}'); + } else if (gitOrgs.isEmpty) { + errors.add('ci.git_orgs must not be empty when provided'); + } else { + final seen = {}; + for (var i = 0; i < gitOrgs.length; i++) { + final org = gitOrgs[i]; + if (org is! String || org.trim().isEmpty) { + errors.add('ci.git_orgs[$i] must be a non-empty string'); + continue; + } + if (org != org.trim()) { + errors.add('ci.git_orgs[$i] must not have leading/trailing whitespace'); + continue; + } + if (!_isSafeGitOrgSegment(org)) { + errors.add('ci.git_orgs[$i] contains unsupported characters: "$org"'); + continue; + } + if (!seen.add(org)) { + errors.add('ci.git_orgs contains duplicate org "$org"'); + } + } + } + } final lineLength = ciConfig['line_length']; if (lineLength != null && lineLength is! int && lineLength is! String) { errors.add('ci.line_length must be a number or string, got ${lineLength.runtimeType}'); diff --git a/templates/config.json b/templates/config.json index fbff096..44f81c1 100644 --- a/templates/config.json +++ b/templates/config.json @@ -68,6 +68,8 @@ "_comment_artifact_retention_days": "Optional: retention for uploaded test artifacts in days (1-90).", "artifact_retention_days": 7, "personal_access_token_secret": "GITHUB_TOKEN", + "_comment_git_orgs": "Optional: organizations to rewrite SSH git URLs for in CI checkout/pub get steps.", + "git_orgs": ["open-runtime", "pieces-app"], "features": { "proto": false, "lfs": false, diff --git a/templates/github/workflows/ci.skeleton.yaml b/templates/github/workflows/ci.skeleton.yaml index b0d5d8f..d0cbf64 100644 --- a/templates/github/workflows/ci.skeleton.yaml +++ b/templates/github/workflows/ci.skeleton.yaml @@ -114,8 +114,9 @@ jobs: echo "::add-mask::${GH_PAT}" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> # ── shared:dart-setup ── keep in sync with multi_platform ── - uses: dart-lang/setup-dart@v1.7.1 @@ -270,8 +271,9 @@ jobs: echo "::add-mask::${GH_PAT}" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> # ── shared:dart-setup ── keep in sync with single_platform ── - uses: dart-lang/setup-dart@v1.7.1 @@ -385,8 +387,9 @@ jobs: echo "::add-mask::${GH_PAT}" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> # ── shared:dart-setup ── keep in sync with single_platform ── - uses: dart-lang/setup-dart@v1.7.1 @@ -497,8 +500,9 @@ jobs: echo "::add-mask::${GH_PAT}" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> # ── shared:dart-setup ── keep in sync with single_platform / multi_platform ── - uses: dart-lang/setup-dart@v1.7.1 diff --git a/test/cli_utils_test.dart b/test/cli_utils_test.dart index d1fe25d..61cf483 100644 --- a/test/cli_utils_test.dart +++ b/test/cli_utils_test.dart @@ -684,6 +684,53 @@ void main() { }); }); + group('SubPackageUtils hierarchical instruction builders', () { + final subPackages = >[ + {'name': 'core', 'path': 'packages/core'}, + {'name': 'api', 'path': 'packages/api'}, + ]; + + test('buildHierarchicalDocumentationInstructions includes package structure', () { + final instructions = SubPackageUtils.buildHierarchicalDocumentationInstructions( + newVersion: '1.2.3', + subPackages: subPackages, + ); + + expect(instructions, contains('Hierarchical Documentation Format')); + expect(instructions, contains('core')); + expect(instructions, contains('api')); + expect(instructions, contains('top-level overview')); + }); + + test('buildHierarchicalAutodocInstructions includes module and package context', () { + final instructions = SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Analyzer Engine', + subPackages: subPackages, + moduleSubPackage: 'core', + ); + + expect(instructions, contains('Multi-Package Autodoc Context')); + expect(instructions, contains('Analyzer Engine')); + expect(instructions, contains('"core"')); + expect(instructions, contains('core, api')); + }); + + test('hierarchical instruction builders return empty for single-package repos', () { + expect( + SubPackageUtils.buildHierarchicalDocumentationInstructions(newVersion: '1.2.3', subPackages: const []), + isEmpty, + ); + expect( + SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Any', + subPackages: const [], + moduleSubPackage: null, + ), + isEmpty, + ); + }); + }); + group('CiProcessRunner.exec', () { test('fatal path exits with process exit code after flushing stdout/stderr', () async { final scriptPath = p.join(p.current, 'test', 'scripts', 'fatal_exit_probe.dart'); diff --git a/test/test_command_test.dart b/test/test_command_test.dart index 7d96468..d9c439c 100644 --- a/test/test_command_test.dart +++ b/test/test_command_test.dart @@ -178,5 +178,114 @@ void main() { throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), ); }); + + test('exits when root tests exceed process timeout', () async { + writeRootPubspec(includeTest: true); + Directory(p.join(tempDir.path, 'test')).createSync(recursive: true); + File(p.join(tempDir.path, 'test', 'slow_test.dart')).writeAsStringSync(''' +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('slow', () async { + await Future.delayed(const Duration(seconds: 5)); + expect(true, isTrue); + }); +} +'''); + + final pubGet = await Process.run('dart', ['pub', 'get'], workingDirectory: tempDir.path); + expect(pubGet.exitCode, equals(0), reason: 'dart pub get must succeed'); + + await expectLater( + () => TestCommand.runWithRoot( + tempDir.path, + processTimeout: const Duration(milliseconds: 1), + exitHandler: _throwingExit, + environment: const {}, + ), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }); + + test('exits when a sub-package test run fails', () async { + writeRootPubspec(); + writeSubPackageConfig([ + {'name': 'pkg_fail', 'path': 'packages/pkg_fail'}, + ]); + + final pkgDir = Directory(p.join(tempDir.path, 'packages', 'pkg_fail'))..createSync(recursive: true); + File(p.join(pkgDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_fail +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(pkgDir.path, 'test')).createSync(recursive: true); + File(p.join(pkgDir.path, 'test', 'failing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('fails', () => expect(1, equals(2))); +} +'''); + + await expectLater( + () => TestCommand.runWithRoot(tempDir.path, exitHandler: _throwingExit, environment: const {}), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('exits when mixed sub-package outcomes include at least one failure', () async { + writeRootPubspec(); + writeSubPackageConfig([ + {'name': 'pkg_pass', 'path': 'packages/pkg_pass'}, + {'name': 'pkg_fail', 'path': 'packages/pkg_fail'}, + ]); + + final passDir = Directory(p.join(tempDir.path, 'packages', 'pkg_pass'))..createSync(recursive: true); + File(p.join(passDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_pass +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(passDir.path, 'test')).createSync(recursive: true); + File(p.join(passDir.path, 'test', 'passing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('passes', () => expect(2 + 2, equals(4))); +} +'''); + + final failDir = Directory(p.join(tempDir.path, 'packages', 'pkg_fail'))..createSync(recursive: true); + File(p.join(failDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_fail +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(failDir.path, 'test')).createSync(recursive: true); + File(p.join(failDir.path, 'test', 'failing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('fails', () => expect(true, isFalse)); +} +'''); + + await expectLater( + () => TestCommand.runWithRoot(tempDir.path, exitHandler: _throwingExit, environment: const {}), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }, timeout: const Timeout(Duration(minutes: 2))); }); } diff --git a/test/workflow_generator_test.dart b/test/workflow_generator_test.dart index 382a9de..af11d18 100644 --- a/test/workflow_generator_test.dart +++ b/test/workflow_generator_test.dart @@ -13,6 +13,7 @@ Map _validConfig({ List? platforms, Map? secrets, String? pat, + List? gitOrgs, dynamic lineLength, List? subPackages, Map? runnerOverrides, @@ -24,6 +25,7 @@ Map _validConfig({ if (platforms != null) 'platforms': platforms, if (secrets != null) 'secrets': secrets, if (pat != null) 'personal_access_token_secret': pat, + if (gitOrgs != null) 'git_orgs': gitOrgs, if (lineLength != null) 'line_length': lineLength, if (subPackages != null) 'sub_packages': subPackages, if (runnerOverrides != null) 'runner_overrides': runnerOverrides, @@ -322,6 +324,41 @@ void main() { }); }); + // ---- git_orgs ---- + group('git_orgs', () { + test('non-list git_orgs produces error', () { + final config = _validConfig(); + config['git_orgs'] = 'open-runtime'; + final errors = WorkflowGenerator.validate(config); + expect(errors, anyElement(contains('git_orgs must be an array'))); + }); + + test('empty git_orgs list produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [])); + expect(errors, anyElement(contains('git_orgs must not be empty'))); + }); + + test('git_orgs entry with whitespace produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [' open-runtime '])); + expect(errors, anyElement(contains('leading/trailing whitespace'))); + }); + + test('git_orgs entry with unsupported characters produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open/runtime'])); + expect(errors, anyElement(contains('unsupported characters'))); + }); + + test('git_orgs duplicate entries produce error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'open-runtime'])); + expect(errors, anyElement(contains('duplicate org "open-runtime"'))); + }); + + test('valid git_orgs list passes', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme'])); + expect(errors.where((e) => e.contains('git_orgs')), isEmpty); + }); + }); + // ---- line_length ---- group('line_length', () { test('non-numeric line_length produces error', () { @@ -1687,6 +1724,22 @@ $base expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); }); + test('custom git_orgs render configurable org rewrite rules', () { + final config = _minimalValidConfig() + ..['git_orgs'] = ['acme-runtime'] + ..['personal_access_token_secret'] = 'GITHUB_TOKEN'; + final gen = WorkflowGenerator(ciConfig: config, toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect( + rendered, + contains( + 'git config --global url."https://x-access-token:\${GH_PAT}@github.com/acme-runtime/".insteadOf "git@github.com:acme-runtime/"', + ), + ); + expect(rendered, isNot(contains('git@github.com:open-runtime/'))); + expect(rendered, isNot(contains('git@github.com:pieces-app/'))); + }); + test('web_test without format_check: web-test needs omits auto-format', () { final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); final rendered = gen.render(); From 4f4d7434c3e3f7e7b27004e28f4adb5b10fb0d53 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Wed, 4 Mar 2026 13:51:40 -0500 Subject: [PATCH 2/3] fix: resolve PR #36 blockers and dedupe CI step templates Addresses review findings by fixing autodoc output path drift, ensuring update --diff previews on local-customization skips, and moving hierarchical autodoc index output to a non-destructive generated file. Also deduplicates shared CI workflow setup/analysis/proto blocks via mustache partials to keep generated workflows consistent and maintainable. --- .github/workflows/ci.yaml | 36 +- .runtime_ci/template_versions.json | 10 +- lib/src/cli/commands/autodoc_command.dart | 23 +- lib/src/cli/commands/update_command.dart | 3 + lib/src/cli/utils/autodoc_scaffold.dart | 40 + lib/src/cli/utils/workflow_generator.dart | 16 +- templates/github/workflows/ci.skeleton.yaml | 331 +--- .../partials/shared_analysis_block.mustache | 45 + .../partials/shared_proto_verify.mustache | 7 + .../shared_setup_through_build.mustache | 58 + test/cli_utils_test.dart | 480 +++-- test/update_command_test.dart | 130 ++ test/workflow_generator_test.dart | 1612 +++++++++++------ 13 files changed, 1773 insertions(+), 1018 deletions(-) create mode 100644 templates/github/workflows/partials/shared_analysis_block.mustache create mode 100644 templates/github/workflows/partials/shared_proto_verify.mustache create mode 100644 templates/github/workflows/partials/shared_setup_through_build.mustache create mode 100644 test/update_command_test.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15c0dde..1c5acaf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -# Generated by runtime_ci_tooling v0.14.4 +# Generated by runtime_ci_tooling v0.15.0 # Configured via .runtime_ci/config.json — run 'dart run runtime_ci_tooling:manage_cicd update --workflows' to regenerate. # Policy: test artifact retention-days = 7 (applied consistently). name: CI @@ -83,14 +83,14 @@ jobs: if: needs.pre-check.outputs.should_run == 'true' runs-on: ubuntu-latest steps: - # ── shared:checkout ── keep in sync with single_platform ── + # ── shared:checkout ── - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.auto-format.outputs.sha }} fetch-depth: 1 persist-credentials: false - # ── shared:git-config ── keep in sync with single_platform ── + # ── shared:git-config ── - name: Configure Git for HTTPS with Token shell: bash env: @@ -102,12 +102,12 @@ jobs: git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - # ── shared:dart-setup ── keep in sync with single_platform ── + # ── shared:dart-setup ── - uses: dart-lang/setup-dart@v1.7.1 with: sdk: "3.9.2" - # ── shared:pub-cache ── keep in sync with single_platform ── + # ── shared:pub-cache ── # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - name: Cache Dart pub dependencies uses: actions/cache@v5.0.3 @@ -116,8 +116,8 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - # ── shared:proto-setup ── keep in sync with single_platform ── - # ── shared:pub-get ── keep in sync with single_platform ── + # ── shared:proto-setup ── + # ── shared:pub-get ── - run: dart pub get env: GIT_LFS_SKIP_SMUDGE: "1" @@ -125,9 +125,9 @@ jobs: - name: Run build_runner run: dart run build_runner build --delete-conflicting-outputs - # ── shared:analysis-cache ── keep in sync with single_platform ── - # ── shared:proto-verify ── keep in sync with single_platform ── - # ── shared:analyze ── keep in sync with single_platform ── + # ── shared:analysis-cache ── + # ── shared:proto-verify ── + # ── shared:analyze ── - name: Analyze run: | dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" @@ -136,7 +136,7 @@ jobs: exit 1 fi - # ── shared:sub-packages ── keep in sync with single_platform ── + # ── shared:sub-packages ── test: needs: [pre-check, analyze, auto-format] if: needs.pre-check.outputs.should_run == 'true' @@ -146,14 +146,14 @@ jobs: matrix: include: [{"platform_id":"ubuntu-x64","runner":"ubuntu-latest","os_family":"linux","arch":"x64"},{"platform_id":"ubuntu-arm64","runner":"runtime-ubuntu-24.04-arm64-208gb-64core","os_family":"linux","arch":"arm64"},{"platform_id":"macos-arm64","runner":"macos-latest","os_family":"macos","arch":"arm64"},{"platform_id":"macos-x64","runner":"macos-15-large","os_family":"macos","arch":"x64"},{"platform_id":"windows-x64","runner":"windows-latest","os_family":"windows","arch":"x64"},{"platform_id":"windows-arm64","runner":"runtime-windows-11-arm64-208gb-64core","os_family":"windows","arch":"arm64"}] steps: - # ── shared:checkout ── keep in sync with single_platform ── + # ── shared:checkout ── - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.auto-format.outputs.sha }} fetch-depth: 1 persist-credentials: false - # ── shared:git-config ── keep in sync with single_platform ── + # ── shared:git-config ── - name: Configure Git for HTTPS with Token shell: bash env: @@ -165,12 +165,12 @@ jobs: git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - # ── shared:dart-setup ── keep in sync with single_platform ── + # ── shared:dart-setup ── - uses: dart-lang/setup-dart@v1.7.1 with: sdk: "3.9.2" - # ── shared:pub-cache ── keep in sync with single_platform ── + # ── shared:pub-cache ── # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - name: Cache Dart pub dependencies uses: actions/cache@v5.0.3 @@ -179,8 +179,8 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - # ── shared:proto-setup ── keep in sync with single_platform ── - # ── shared:pub-get ── keep in sync with single_platform ── + # ── shared:proto-setup ── + # ── shared:pub-get ── - run: dart pub get env: GIT_LFS_SKIP_SMUDGE: "1" @@ -191,7 +191,7 @@ jobs: # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- - # ── shared:test ── keep in sync with single_platform ── + # ── shared:test ── - name: Test shell: bash run: | diff --git a/.runtime_ci/template_versions.json b/.runtime_ci/template_versions.json index 00c2852..83cc814 100644 --- a/.runtime_ci/template_versions.json +++ b/.runtime_ci/template_versions.json @@ -1,6 +1,6 @@ { - "tooling_version": "0.14.4", - "updated_at": "2026-03-04T01:43:45.360518Z", + "tooling_version": "0.15.0", + "updated_at": "2026-03-04T18:46:17.822889Z", "templates": { "gemini_settings": { "hash": "93983f49dd2f40d2ed245271854946d8916b8f0698ed2cfaf12058305baa0b08", @@ -23,9 +23,9 @@ "updated_at": "2026-02-24T00:59:57.620091Z" }, "workflow_ci": { - "hash": "e2df47cd0f26ec20fcb7c01530f5ad84c5877c8397518bf4d46de5f99bb8bbf2", - "consumer_hash": "c23d7a119a57783b1b6b3472ca7b52a912fa67435e6d0f5448c414e1274a025b", - "updated_at": "2026-03-04T01:43:45.357518Z" + "hash": "1d5ab4ac372b1d5c9ff1ad9cd2cc634fea66a98d264faa9e487acfc873bf7602", + "consumer_hash": "f25b1db7a45db7631808fb1eb3135df963eece9935e0eabd89aff3d6ffe67d73", + "updated_at": "2026-03-04T18:46:17.823530Z" }, "workflow_release": { "hash": "0db4e621f478e5f255292bc3d9a0c1a6dcd78ebe73fd91d87b7facd635898f64", diff --git a/lib/src/cli/commands/autodoc_command.dart b/lib/src/cli/commands/autodoc_command.dart index 5cd9cc0..8b73861 100644 --- a/lib/src/cli/commands/autodoc_command.dart +++ b/lib/src/cli/commands/autodoc_command.dart @@ -10,7 +10,7 @@ import '../../triage/utils/config.dart'; import '../../triage/utils/run_context.dart'; import '../manage_cicd_cli.dart'; import '../options/autodoc_options.dart'; -import '../utils/autodoc_scaffold.dart'; +import '../utils/autodoc_scaffold.dart' show kAutodocIndexPath, resolveAutodocOutputPath, scaffoldAutodocJson; import '../utils/gemini_utils.dart'; import '../utils/logger.dart'; import '../utils/process_runner.dart'; @@ -606,17 +606,7 @@ Write the corrected file to the same path: $absOutputFile } String _resolveOutputPathForModule({required String configuredOutputPath, required String? moduleSubPackage}) { - final normalized = p.posix.normalize(configuredOutputPath).replaceFirst(RegExp(r'^/+'), ''); - if (moduleSubPackage == null || moduleSubPackage.isEmpty) { - return normalized; - } - - final docsPrefix = 'docs/'; - final stripped = normalized.startsWith(docsPrefix) ? normalized.substring(docsPrefix.length) : normalized; - if (stripped.startsWith('$moduleSubPackage/')) { - return '$docsPrefix$stripped'; - } - return '$docsPrefix$moduleSubPackage/$stripped'; + return resolveAutodocOutputPath(configuredOutputPath: configuredOutputPath, moduleSubPackage: moduleSubPackage); } void _writeHierarchicalDocsIndex({ @@ -643,6 +633,8 @@ Write the corrected file to the same path: $absOutputFile } final buf = StringBuffer() + ..writeln('') + ..writeln() ..writeln('# Documentation Index') ..writeln() ..writeln('Generated by `manage_cicd autodoc` for multi-package repositories.') @@ -662,8 +654,9 @@ Write the corrected file to the same path: $absOutputFile buf.writeln(); } - final docsDir = Directory('$repoRoot/docs')..createSync(recursive: true); - File('${docsDir.path}/README.md').writeAsStringSync(buf.toString()); - Logger.info('Updated hierarchical docs index: docs/README.md'); + Directory('$repoRoot/docs').createSync(recursive: true); + final indexFile = File('$repoRoot/$kAutodocIndexPath'); + indexFile.writeAsStringSync(buf.toString()); + Logger.info('Updated hierarchical docs index: $kAutodocIndexPath'); } } diff --git a/lib/src/cli/commands/update_command.dart b/lib/src/cli/commands/update_command.dart index a9c377a..e8ab1ad 100644 --- a/lib/src/cli/commands/update_command.dart +++ b/lib/src/cli/commands/update_command.dart @@ -247,6 +247,9 @@ class UpdateCommand extends Command { final hasLocalChanges = previousConsumerHash != null && currentDestHash != previousConsumerHash; if (hasLocalChanges && !force) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } Logger.warn(' ${entry.id}: local customizations detected, skipping (use --force to overwrite)'); return _UpdateResult(entry.id, 'warning', reason: 'local customizations -- use --force'); } diff --git a/lib/src/cli/utils/autodoc_scaffold.dart b/lib/src/cli/utils/autodoc_scaffold.dart index 3acebd7..033ec09 100644 --- a/lib/src/cli/utils/autodoc_scaffold.dart +++ b/lib/src/cli/utils/autodoc_scaffold.dart @@ -1,10 +1,50 @@ import 'dart:convert'; import 'dart:io'; +import 'package:path/path.dart' as p; + import '../../triage/utils/run_context.dart'; import 'logger.dart'; import 'sub_package_utils.dart'; +/// Relative path for the hierarchical docs index generated by `manage_cicd autodoc`. +/// +/// Uses a dedicated file (not docs/README.md) to avoid overwriting user-maintained +/// policy or documentation. Content is clearly marked as auto-generated. +const String kAutodocIndexPath = 'docs/AUTODOC_INDEX.md'; + +/// Resolves the output path for an autodoc module, scoping by [moduleSubPackage] +/// when present. +/// +/// Treats paths equal to `docs/` or `docs//...` as +/// already scoped to avoid duplicating the package segment (e.g. docs/pkg/pkg). +/// This prevents config drift when the configured path is already correct. +String resolveAutodocOutputPath({required String configuredOutputPath, required String? moduleSubPackage}) { + final normalized = p.posix.normalize(configuredOutputPath).replaceFirst(RegExp(r'^/+'), ''); + if (moduleSubPackage == null || moduleSubPackage.isEmpty) { + return normalized; + } + + final docsPrefix = 'docs/'; + // Only scope root docs paths. Sub-package paths like + // `packages//docs/...` are already explicitly scoped and must be + // preserved to avoid rewriting into `docs//packages/...`. + if (normalized != 'docs' && !normalized.startsWith(docsPrefix)) { + return normalized; + } + final stripped = normalized.startsWith(docsPrefix) ? normalized.substring(docsPrefix.length) : normalized; + // Root docs path (empty or bare "docs") -> scope to docs/. + if (stripped.isEmpty || stripped == 'docs') { + return '$docsPrefix$moduleSubPackage'; + } + // Treat exact package path (docs/) or docs//... as + // already scoped to avoid duplicating the package segment. + if (stripped == moduleSubPackage || stripped.startsWith('$moduleSubPackage/')) { + return '$docsPrefix$stripped'; + } + return '$docsPrefix$moduleSubPackage/$stripped'; +} + /// Capitalize a snake_case name into a display-friendly title. /// /// Splits on `_`, capitalizes the first letter of each non-empty segment, diff --git a/lib/src/cli/utils/workflow_generator.dart b/lib/src/cli/utils/workflow_generator.dart index 6729dbc..16cd555 100644 --- a/lib/src/cli/utils/workflow_generator.dart +++ b/lib/src/cli/utils/workflow_generator.dart @@ -86,11 +86,25 @@ bool _isSafeSubPackageName(String s) { return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); } +/// Resolves shared step partials for CI workflow generation. +/// Partials live in templates/github/workflows/partials/ and eliminate +/// duplicated step definitions across jobs (single_platform, multi_platform, web_test). +Template? _resolvePartial(String name) { + final path = TemplateResolver.resolveTemplatePath('github/workflows/partials/$name.mustache'); + final file = File(path); + if (!file.existsSync()) return null; + final source = file.readAsStringSync(); + return Template(source, htmlEscapeValues: false, name: 'partial:$name'); +} + /// Renders CI workflow YAML from a Mustache skeleton template and config.json. /// /// The skeleton uses `<% %>` delimiters (set via `{{=<% %>=}}` at the top) /// to avoid conflict with GitHub Actions' `${{ }}` syntax. /// +/// Shared step blocks are defined once in partials/ and included via +/// `{{> partial_name}}` to avoid duplication. +/// /// User-preservable sections are delimited by: /// `# --- BEGIN USER: ---` /// `# --- END USER: ---` @@ -183,7 +197,7 @@ class WorkflowGenerator { final skeleton = skeletonFile.readAsStringSync(); final context = _buildContext(); - final template = Template(skeleton, htmlEscapeValues: false); + final template = Template(skeleton, htmlEscapeValues: false, partialResolver: _resolvePartial); var rendered = template.renderString(context); // Re-insert user sections from existing file diff --git a/templates/github/workflows/ci.skeleton.yaml b/templates/github/workflows/ci.skeleton.yaml index d0cbf64..3d0c5fb 100644 --- a/templates/github/workflows/ci.skeleton.yaml +++ b/templates/github/workflows/ci.skeleton.yaml @@ -93,107 +93,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with multi_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with multi_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" -<%#git_orgs%> - git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" -<%/git_orgs%> - - # ── shared:dart-setup ── keep in sync with multi_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with multi_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with multi_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with multi_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:analysis-cache ── keep in sync with multi_platform ── -<%#analysis_cache%> - - name: Cache Dart analysis - uses: actions/cache@v5.0.3 - with: - path: ~/.dartServer - key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- - -<%/analysis_cache%> - # ── shared:proto-verify ── keep in sync with multi_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> - # ── shared:analyze ── keep in sync with multi_platform ── -<%#managed_analyze%> - - name: Analyze - run: dart run runtime_ci_tooling:manage_cicd analyze -<%/managed_analyze%> -<%^managed_analyze%> - - name: Analyze - run: | - dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then - echo "::error::Analysis errors found" - exit 1 - fi -<%/managed_analyze%> - - # ── shared:sub-packages ── keep in sync with multi_platform ── -<%#sub_packages%> - - name: Analyze (<%name%>) - working-directory: <%path%> - run: | - GIT_LFS_SKIP_SMUDGE=1 dart pub get - dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then - echo "::error::Errors found in <%name%>" - exit 1 - fi - -<%/sub_packages%> +<%> shared_setup_through_build %> +<%> shared_analysis_block %> # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- @@ -250,107 +151,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" -<%#git_orgs%> - git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" -<%/git_orgs%> - - # ── shared:dart-setup ── keep in sync with single_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:analysis-cache ── keep in sync with single_platform ── -<%#analysis_cache%> - - name: Cache Dart analysis - uses: actions/cache@v5.0.3 - with: - path: ~/.dartServer - key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- - -<%/analysis_cache%> - # ── shared:proto-verify ── keep in sync with single_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> - # ── shared:analyze ── keep in sync with single_platform ── -<%#managed_analyze%> - - name: Analyze - run: dart run runtime_ci_tooling:manage_cicd analyze -<%/managed_analyze%> -<%^managed_analyze%> - - name: Analyze - run: | - dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then - echo "::error::Analysis errors found" - exit 1 - fi -<%/managed_analyze%> - - # ── shared:sub-packages ── keep in sync with single_platform ── -<%#sub_packages%> - - name: Analyze (<%name%>) - working-directory: <%path%> - run: | - GIT_LFS_SKIP_SMUDGE=1 dart pub get - dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then - echo "::error::Errors found in <%name%>" - exit 1 - fi - -<%/sub_packages%> +<%> shared_setup_through_build %> +<%> shared_analysis_block %> test: needs: [pre-check, analyze<%#format_check%>, auto-format<%/format_check%>] if: needs.pre-check.outputs.should_run == 'true' @@ -366,67 +168,11 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" -<%#git_orgs%> - git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" -<%/git_orgs%> - - # ── shared:dart-setup ── keep in sync with single_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> +<%> shared_setup_through_build %> # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- - # ── shared:test ── keep in sync with single_platform ── + # ── shared:test ── <%#managed_test%> - name: Test shell: bash @@ -479,69 +225,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform / multi_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform / multi_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" -<%#git_orgs%> - git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" -<%/git_orgs%> - - # ── shared:dart-setup ── keep in sync with single_platform / multi_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform / multi_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform / multi_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform / multi_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:proto-verify ── keep in sync with single_platform / multi_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> +<%> shared_setup_through_build %> +<%> shared_proto_verify %> - name: Setup Chrome id: setup-chrome uses: browser-actions/setup-chrome@4f8e94349a351df0f048634f25fec36c3c91eded # v2.1.1 diff --git a/templates/github/workflows/partials/shared_analysis_block.mustache b/templates/github/workflows/partials/shared_analysis_block.mustache new file mode 100644 index 0000000..250cbfd --- /dev/null +++ b/templates/github/workflows/partials/shared_analysis_block.mustache @@ -0,0 +1,45 @@ +{{=<% %>=}} + # ── shared:analysis-cache ── +<%#analysis_cache%> + - name: Cache Dart analysis + uses: actions/cache@v5.0.3 + with: + path: ~/.dartServer + key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- + +<%/analysis_cache%> + # ── shared:proto-verify ── +<%#proto%> + - name: Verify proto files + run: dart run runtime_ci_tooling:manage_cicd verify-protos + +<%/proto%> + # ── shared:analyze ── +<%#managed_analyze%> + - name: Analyze + run: dart run runtime_ci_tooling:manage_cicd analyze +<%/managed_analyze%> +<%^managed_analyze%> + - name: Analyze + run: | + dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" + if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then + echo "::error::Analysis errors found" + exit 1 + fi +<%/managed_analyze%> + + # ── shared:sub-packages ── +<%#sub_packages%> + - name: Analyze (<%name%>) + working-directory: <%path%> + run: | + GIT_LFS_SKIP_SMUDGE=1 dart pub get + dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" + if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then + echo "::error::Errors found in <%name%>" + exit 1 + fi + +<%/sub_packages%> diff --git a/templates/github/workflows/partials/shared_proto_verify.mustache b/templates/github/workflows/partials/shared_proto_verify.mustache new file mode 100644 index 0000000..68da3f1 --- /dev/null +++ b/templates/github/workflows/partials/shared_proto_verify.mustache @@ -0,0 +1,7 @@ +{{=<% %>=}} + # ── shared:proto-verify ── +<%#proto%> + - name: Verify proto files + run: dart run runtime_ci_tooling:manage_cicd verify-protos + +<%/proto%> diff --git a/templates/github/workflows/partials/shared_setup_through_build.mustache b/templates/github/workflows/partials/shared_setup_through_build.mustache new file mode 100644 index 0000000..9628ee5 --- /dev/null +++ b/templates/github/workflows/partials/shared_setup_through_build.mustache @@ -0,0 +1,58 @@ +{{=<% %>=}} + # ── shared:checkout ── + - uses: actions/checkout@v6.0.2 + with: +<%#format_check%> + ref: ${{ needs.auto-format.outputs.sha }} +<%/format_check%> + fetch-depth: 1 + persist-credentials: false +<%#lfs%> + lfs: true +<%/lfs%> + + # ── shared:git-config ── + - name: Configure Git for HTTPS with Token + shell: bash + env: + GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} + run: | + echo "::add-mask::${GH_PAT}" + git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" + git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> + + # ── shared:dart-setup ── + - uses: dart-lang/setup-dart@v1.7.1 + with: + sdk: "<%dart_sdk%>" + + # ── shared:pub-cache ── + # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache + - name: Cache Dart pub dependencies + uses: actions/cache@v5.0.3 + with: + path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} + key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- + + # ── shared:proto-setup ── +<%#proto%> + - name: Install protoc + uses: arduino/setup-protoc@v3.0.0 + + - run: dart pub global activate protoc_plugin 25.0.0 + +<%/proto%> + # ── shared:pub-get ── + - run: dart pub get + env: + GIT_LFS_SKIP_SMUDGE: "1" + +<%#build_runner%> + - name: Run build_runner + run: dart run build_runner build --delete-conflicting-outputs + +<%/build_runner%> diff --git a/test/cli_utils_test.dart b/test/cli_utils_test.dart index 61cf483..00cbe38 100644 --- a/test/cli_utils_test.dart +++ b/test/cli_utils_test.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart' hide TestFailure; +import 'package:runtime_ci_tooling/src/cli/utils/autodoc_scaffold.dart' + show kAutodocIndexPath, resolveAutodocOutputPath; import 'package:runtime_ci_tooling/src/cli/utils/process_runner.dart'; import 'package:runtime_ci_tooling/src/cli/utils/repo_utils.dart'; import 'package:runtime_ci_tooling/src/cli/utils/step_summary.dart'; @@ -47,7 +49,10 @@ void main() { }); test('returns default path when TEST_LOG_DIR is unset', () { - final resolved = RepoUtils.resolveTestLogDir(repoRoot, environment: const {}); + final resolved = RepoUtils.resolveTestLogDir( + repoRoot, + environment: const {}, + ); expect(resolved, equals(p.join(repoRoot, '.dart_tool', 'test-logs'))); }); @@ -71,8 +76,10 @@ void main() { test('throws when TEST_LOG_DIR is relative', () { expect( - () => - RepoUtils.resolveTestLogDir(repoRoot, environment: const {'TEST_LOG_DIR': 'relative/path'}), + () => RepoUtils.resolveTestLogDir( + repoRoot, + environment: const {'TEST_LOG_DIR': 'relative/path'}, + ), throwsA(isA()), ); }); @@ -81,7 +88,10 @@ void main() { final runnerTemp = p.join(repoRoot, 'runner-temp'); final outside = p.join(repoRoot, 'outside', 'logs'); expect( - () => RepoUtils.resolveTestLogDir(repoRoot, environment: {'RUNNER_TEMP': runnerTemp, 'TEST_LOG_DIR': outside}), + () => RepoUtils.resolveTestLogDir( + repoRoot, + environment: {'RUNNER_TEMP': runnerTemp, 'TEST_LOG_DIR': outside}, + ), throwsA(isA()), ); }); @@ -101,7 +111,10 @@ void main() { expect( () => RepoUtils.resolveTestLogDir( repoRoot, - environment: {'RUNNER_TEMP': '/tmp/runner\nbad', 'TEST_LOG_DIR': inside}, + environment: { + 'RUNNER_TEMP': '/tmp/runner\nbad', + 'TEST_LOG_DIR': inside, + }, ), throwsA(isA()), ); @@ -141,19 +154,35 @@ void main() { expect(File(filePath).readAsStringSync(), equals('hello world')); }); - test('ensureSafeDirectory rejects symlink-backed directories', skip: !symlinksSupported, () { - final targetDir = Directory(p.join(tempDir.path, 'target'))..createSync(recursive: true); - final linkDirPath = p.join(tempDir.path, 'linked'); - Link(linkDirPath).createSync(targetDir.path); - expect(() => RepoUtils.ensureSafeDirectory(linkDirPath), throwsA(isA())); - }); - - test('writeFileSafely rejects symlink file targets', skip: !symlinksSupported, () { - final targetFile = File(p.join(tempDir.path, 'target.txt'))..writeAsStringSync('base'); - final linkPath = p.join(tempDir.path, 'linked.txt'); - Link(linkPath).createSync(targetFile.path); - expect(() => RepoUtils.writeFileSafely(linkPath, 'new content'), throwsA(isA())); - }); + test( + 'ensureSafeDirectory rejects symlink-backed directories', + skip: !symlinksSupported, + () { + final targetDir = Directory(p.join(tempDir.path, 'target')) + ..createSync(recursive: true); + final linkDirPath = p.join(tempDir.path, 'linked'); + Link(linkDirPath).createSync(targetDir.path); + expect( + () => RepoUtils.ensureSafeDirectory(linkDirPath), + throwsA(isA()), + ); + }, + ); + + test( + 'writeFileSafely rejects symlink file targets', + skip: !symlinksSupported, + () { + final targetFile = File(p.join(tempDir.path, 'target.txt')) + ..writeAsStringSync('base'); + final linkPath = p.join(tempDir.path, 'linked.txt'); + Link(linkPath).createSync(targetFile.path); + expect( + () => RepoUtils.writeFileSafely(linkPath, 'new content'), + throwsA(isA()), + ); + }, + ); }); group('TestResultsUtil.parseTestResultsJson', () { @@ -190,27 +219,35 @@ void main() { expect(results.failures, isEmpty); }); - test('returns unparsed results when NDJSON file has only blank lines', () async { - final jsonPath = p.join(tempDir.path, 'blank.json'); - File(jsonPath).writeAsStringSync('\n \n\t\n'); - final results = await TestResultsUtil.parseTestResultsJson(jsonPath); - expect(results.parsed, isFalse); - expect(results.passed, equals(0)); - expect(results.failed, equals(0)); - expect(results.skipped, equals(0)); - expect(results.failures, isEmpty); - }); - - test('returns unparsed results when file has valid JSON but no structured events', () async { - final jsonPath = p.join(tempDir.path, 'no_events.json'); - File(jsonPath).writeAsStringSync('{"type":"unknown","data":1}\n{"other":"value"}\n'); - final results = await TestResultsUtil.parseTestResultsJson(jsonPath); - expect(results.parsed, isFalse); - expect(results.passed, equals(0)); - expect(results.failed, equals(0)); - expect(results.skipped, equals(0)); - expect(results.failures, isEmpty); - }); + test( + 'returns unparsed results when NDJSON file has only blank lines', + () async { + final jsonPath = p.join(tempDir.path, 'blank.json'); + File(jsonPath).writeAsStringSync('\n \n\t\n'); + final results = await TestResultsUtil.parseTestResultsJson(jsonPath); + expect(results.parsed, isFalse); + expect(results.passed, equals(0)); + expect(results.failed, equals(0)); + expect(results.skipped, equals(0)); + expect(results.failures, isEmpty); + }, + ); + + test( + 'returns unparsed results when file has valid JSON but no structured events', + () async { + final jsonPath = p.join(tempDir.path, 'no_events.json'); + File( + jsonPath, + ).writeAsStringSync('{"type":"unknown","data":1}\n{"other":"value"}\n'); + final results = await TestResultsUtil.parseTestResultsJson(jsonPath); + expect(results.parsed, isFalse); + expect(results.passed, equals(0)); + expect(results.failed, equals(0)); + expect(results.skipped, equals(0)); + expect(results.failures, isEmpty); + }, + ); test('parses pass/fail/skipped counts and failure details', () async { final jsonPath = p.join(tempDir.path, 'results.json'); @@ -340,7 +377,12 @@ void main() { }); group('TestResultsUtil.writeTestJobSummary', () { - TestResults _parsed({required int passed, required int failed, required int skipped, int durationMs = 500}) { + TestResults _parsed({ + required int passed, + required int failed, + required int skipped, + int durationMs = 500, + }) { final results = TestResults() ..parsed = true ..passed = passed @@ -350,39 +392,50 @@ void main() { return results; } - test('emits NOTE when parsed results are successful and exit code is 0', () { - String? summary; - final results = _parsed(passed: 3, failed: 0, skipped: 1); - - TestResultsUtil.writeTestJobSummary( - results, - 0, - platformId: 'linux-x64', - writeSummary: (markdown) => summary = markdown, - ); - - expect(summary, isNotNull); - expect(summary!, contains('## Test Results — linux-x64')); - expect(summary!, contains('> [!NOTE]')); - expect(summary!, contains('All 4 tests passed')); - }); - - test('emits CAUTION when exit code is non-zero even if failed count is zero', () { - String? summary; - final results = _parsed(passed: 2, failed: 0, skipped: 0); + test( + 'emits NOTE when parsed results are successful and exit code is 0', + () { + String? summary; + final results = _parsed(passed: 3, failed: 0, skipped: 1); + + TestResultsUtil.writeTestJobSummary( + results, + 0, + platformId: 'linux-x64', + writeSummary: (markdown) => summary = markdown, + ); - TestResultsUtil.writeTestJobSummary( - results, - 1, - platformId: 'linux ', - writeSummary: (markdown) => summary = markdown, - ); + expect(summary, isNotNull); + expect(summary!, contains('## Test Results — linux-x64')); + expect(summary!, contains('> [!NOTE]')); + expect(summary!, contains('All 4 tests passed')); + }, + ); + + test( + 'emits CAUTION when exit code is non-zero even if failed count is zero', + () { + String? summary; + final results = _parsed(passed: 2, failed: 0, skipped: 0); + + TestResultsUtil.writeTestJobSummary( + results, + 1, + platformId: 'linux ', + writeSummary: (markdown) => summary = markdown, + ); - expect(summary, isNotNull); - expect(summary!, contains('## Test Results — linux <x64>')); - expect(summary!, contains('> [!CAUTION]')); - expect(summary!, contains('Tests exited with code 1 despite no structured test failures.')); - }); + expect(summary, isNotNull); + expect(summary!, contains('## Test Results — linux <x64>')); + expect(summary!, contains('> [!CAUTION]')); + expect( + summary!, + contains( + 'Tests exited with code 1 despite no structured test failures.', + ), + ); + }, + ); test('emits CAUTION for unparsed results with non-zero exit code', () { String? summary; @@ -397,7 +450,12 @@ void main() { expect(summary, isNotNull); expect(summary!, contains('> [!CAUTION]')); - expect(summary!, contains('Tests failed (exit code 7) — no structured results available.')); + expect( + summary!, + contains( + 'Tests failed (exit code 7) — no structured results available.', + ), + ); }); test('emits NOTE for unparsed results with zero exit code', () { @@ -413,14 +471,25 @@ void main() { expect(summary, isNotNull); expect(summary!, contains('> [!NOTE]')); - expect(summary!, contains('Tests passed (exit code 0) — no structured results available.')); + expect( + summary!, + contains( + 'Tests passed (exit code 0) — no structured results available.', + ), + ); }); test('emits CAUTION when parsed results contain failures', () { String? summary; final results = _parsed(passed: 1, failed: 1, skipped: 0); results.failures.add( - TestFailure(name: 'failing test', error: 'boom', stackTrace: 'trace', printOutput: '', durationMs: 12), + TestFailure( + name: 'failing test', + error: 'boom', + stackTrace: 'trace', + printOutput: '', + durationMs: 12, + ), ); TestResultsUtil.writeTestJobSummary( @@ -460,7 +529,12 @@ void main() { ); expect(summary, isNotNull); - expect(summary!, contains('_...and 5 more failures. See test logs artifact for full details._')); + expect( + summary!, + contains( + '_...and 5 more failures. See test logs artifact for full details._', + ), + ); expect(summary!, isNot(contains('failing test 24'))); }); @@ -520,7 +594,10 @@ void main() { group('Utf8BoundedBuffer', () { test('appends full content when under byte limit', () { - final buffer = Utf8BoundedBuffer(maxBytes: 20, truncationSuffix: '...[truncated]'); + final buffer = Utf8BoundedBuffer( + maxBytes: 20, + truncationSuffix: '...[truncated]', + ); buffer.append('hello'); buffer.append(' world'); expect(buffer.isTruncated, isFalse); @@ -537,12 +614,18 @@ void main() { expect(buffer.byteLength, equals(9)); }); - test('never exceeds maxBytes even when suffix is longer than remaining budget', () { - final buffer = Utf8BoundedBuffer(maxBytes: 4, truncationSuffix: '...[truncated]'); - buffer.append('abcdefgh'); - expect(buffer.isTruncated, isTrue); - expect(utf8.encode(buffer.toString()).length, lessThanOrEqualTo(4)); - }); + test( + 'never exceeds maxBytes even when suffix is longer than remaining budget', + () { + final buffer = Utf8BoundedBuffer( + maxBytes: 4, + truncationSuffix: '...[truncated]', + ); + buffer.append('abcdefgh'); + expect(buffer.isTruncated, isTrue); + expect(utf8.encode(buffer.toString()).length, lessThanOrEqualTo(4)); + }, + ); }); group('StepSummary', () { @@ -559,7 +642,10 @@ void main() { File(summaryPath).writeAsStringSync('x' * (maxBytes - 2)); expect(File(summaryPath).lengthSync(), equals(maxBytes - 2)); - StepSummary.write('語', environment: {'GITHUB_STEP_SUMMARY': summaryPath}); + StepSummary.write( + '語', + environment: {'GITHUB_STEP_SUMMARY': summaryPath}, + ); // Should skip append (would exceed); file size unchanged expect(File(summaryPath).lengthSync(), equals(maxBytes - 2)); } finally { @@ -595,7 +681,9 @@ void main() { void _writeConfig(Map ci) { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File('${configDir.path}/config.json').writeAsStringSync(json.encode({'ci': ci})); + File( + '${configDir.path}/config.json', + ).writeAsStringSync(json.encode({'ci': ci})); } test('returns empty when no sub_packages', () { @@ -690,77 +778,205 @@ void main() { {'name': 'api', 'path': 'packages/api'}, ]; - test('buildHierarchicalDocumentationInstructions includes package structure', () { - final instructions = SubPackageUtils.buildHierarchicalDocumentationInstructions( - newVersion: '1.2.3', - subPackages: subPackages, - ); + test( + 'buildHierarchicalDocumentationInstructions includes package structure', + () { + final instructions = + SubPackageUtils.buildHierarchicalDocumentationInstructions( + newVersion: '1.2.3', + subPackages: subPackages, + ); + + expect(instructions, contains('Hierarchical Documentation Format')); + expect(instructions, contains('core')); + expect(instructions, contains('api')); + expect(instructions, contains('top-level overview')); + }, + ); + + test( + 'buildHierarchicalAutodocInstructions includes module and package context', + () { + final instructions = + SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Analyzer Engine', + subPackages: subPackages, + moduleSubPackage: 'core', + ); + + expect(instructions, contains('Multi-Package Autodoc Context')); + expect(instructions, contains('Analyzer Engine')); + expect(instructions, contains('"core"')); + expect(instructions, contains('core, api')); + }, + ); + + test( + 'hierarchical instruction builders return empty for single-package repos', + () { + expect( + SubPackageUtils.buildHierarchicalDocumentationInstructions( + newVersion: '1.2.3', + subPackages: const [], + ), + isEmpty, + ); + expect( + SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Any', + subPackages: const [], + moduleSubPackage: null, + ), + isEmpty, + ); + }, + ); + }); + + group('autodoc index path', () { + test('uses dedicated file under docs, not README.md', () { + expect(kAutodocIndexPath, isNot(equals('docs/README.md'))); + expect(kAutodocIndexPath, startsWith('docs/')); + expect(kAutodocIndexPath, endsWith('.md')); + }); + + test('path indicates auto-generated content', () { + expect(kAutodocIndexPath, contains('AUTODOC')); + expect(kAutodocIndexPath, contains('INDEX')); + }); + }); - expect(instructions, contains('Hierarchical Documentation Format')); - expect(instructions, contains('core')); - expect(instructions, contains('api')); - expect(instructions, contains('top-level overview')); + group('resolveAutodocOutputPath', () { + test('returns configured path unchanged when no moduleSubPackage', () { + expect( + resolveAutodocOutputPath( + configuredOutputPath: 'docs/foo', + moduleSubPackage: null, + ), + equals('docs/foo'), + ); + expect( + resolveAutodocOutputPath( + configuredOutputPath: 'docs', + moduleSubPackage: null, + ), + equals('docs'), + ); }); - test('buildHierarchicalAutodocInstructions includes module and package context', () { - final instructions = SubPackageUtils.buildHierarchicalAutodocInstructions( - moduleName: 'Analyzer Engine', - subPackages: subPackages, - moduleSubPackage: 'core', + test('treats docs/ as already scoped (no duplication)', () { + expect( + resolveAutodocOutputPath( + configuredOutputPath: 'docs/my_pkg', + moduleSubPackage: 'my_pkg', + ), + equals('docs/my_pkg'), ); + }); - expect(instructions, contains('Multi-Package Autodoc Context')); - expect(instructions, contains('Analyzer Engine')); - expect(instructions, contains('"core"')); - expect(instructions, contains('core, api')); + test('treats docs//nested as already scoped', () { + expect( + resolveAutodocOutputPath( + configuredOutputPath: 'docs/my_pkg/api', + moduleSubPackage: 'my_pkg', + ), + equals('docs/my_pkg/api'), + ); }); - test('hierarchical instruction builders return empty for single-package repos', () { + test('scopes unscoped path when moduleSubPackage present', () { expect( - SubPackageUtils.buildHierarchicalDocumentationInstructions(newVersion: '1.2.3', subPackages: const []), - isEmpty, + resolveAutodocOutputPath( + configuredOutputPath: 'docs', + moduleSubPackage: 'my_pkg', + ), + equals('docs/my_pkg'), ); expect( - SubPackageUtils.buildHierarchicalAutodocInstructions( - moduleName: 'Any', - subPackages: const [], - moduleSubPackage: null, + resolveAutodocOutputPath( + configuredOutputPath: 'docs/other', + moduleSubPackage: 'my_pkg', ), - isEmpty, + equals('docs/my_pkg/other'), ); }); + + test('preserves sub-package scoped docs paths outside root docs/', () { + expect( + resolveAutodocOutputPath( + configuredOutputPath: 'packages/core/docs/utils/', + moduleSubPackage: 'core', + ), + equals('packages/core/docs/utils'), + ); + }); + + test('normalization is idempotent (no drift across runs)', () { + const path = 'docs/my_pkg'; + const subPkg = 'my_pkg'; + final first = resolveAutodocOutputPath( + configuredOutputPath: path, + moduleSubPackage: subPkg, + ); + final second = resolveAutodocOutputPath( + configuredOutputPath: first, + moduleSubPackage: subPkg, + ); + expect(first, equals(second)); + expect(first, equals('docs/my_pkg')); + }); }); group('CiProcessRunner.exec', () { - test('fatal path exits with process exit code after flushing stdout/stderr', () async { - final scriptPath = p.join(p.current, 'test', 'scripts', 'fatal_exit_probe.dart'); - final result = Process.runSync(Platform.resolvedExecutable, ['run', scriptPath], runInShell: false); - final expectedCode = Platform.isWindows ? 7 : 1; - expect(result.exitCode, equals(expectedCode), reason: 'fatal exec should exit with failing command exit code'); - }); + test( + 'fatal path exits with process exit code after flushing stdout/stderr', + () async { + final scriptPath = p.join( + p.current, + 'test', + 'scripts', + 'fatal_exit_probe.dart', + ); + final result = Process.runSync(Platform.resolvedExecutable, [ + 'run', + scriptPath, + ], runInShell: false); + final expectedCode = Platform.isWindows ? 7 : 1; + expect( + result.exitCode, + equals(expectedCode), + reason: 'fatal exec should exit with failing command exit code', + ); + }, + ); }); group('CiProcessRunner.runWithTimeout', () { test('completes normally when process finishes within timeout', () async { - final result = await CiProcessRunner.runWithTimeout(Platform.resolvedExecutable, [ - '--version', - ], timeout: const Duration(seconds: 10)); + final result = await CiProcessRunner.runWithTimeout( + Platform.resolvedExecutable, + ['--version'], + timeout: const Duration(seconds: 10), + ); expect(result.exitCode, equals(0)); expect(result.stdout, contains('Dart')); }); - test('returns timeout result and kills process when timeout exceeded', () async { - final executable = Platform.isWindows ? 'ping' : 'sleep'; - final args = Platform.isWindows ? ['127.0.0.1', '-n', '60'] : ['60']; - final result = await CiProcessRunner.runWithTimeout( - executable, - args, - timeout: const Duration(milliseconds: 500), - timeoutExitCode: 124, - timeoutMessage: 'Timed out', - ); - expect(result.exitCode, equals(124)); - expect(result.stderr, equals('Timed out')); - }); + test( + 'returns timeout result and kills process when timeout exceeded', + () async { + final executable = Platform.isWindows ? 'ping' : 'sleep'; + final args = Platform.isWindows ? ['127.0.0.1', '-n', '60'] : ['60']; + final result = await CiProcessRunner.runWithTimeout( + executable, + args, + timeout: const Duration(milliseconds: 500), + timeoutExitCode: 124, + timeoutMessage: 'Timed out', + ); + expect(result.exitCode, equals(124)); + expect(result.stderr, equals('Timed out')); + }, + ); }); } diff --git a/test/update_command_test.dart b/test/update_command_test.dart new file mode 100644 index 0000000..9cba07a --- /dev/null +++ b/test/update_command_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Tests for the update command, including PR #36: --diff must emit unified +/// diff on the local-customization skip path for overwritable files. +void main() { + group('UpdateCommand overwritable local-customization --diff', () { + late Directory tempDir; + late String packagePath; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('update_cmd_'); + // Package root = CWD when running `dart test` from package root + packagePath = p.normalize(Directory.current.path); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + test( + 'emits unified diff when skipping due to local customizations and --diff is set', + () async { + // Consumer repo: pubspec, config, overwritable file with local edits, + // template_versions with consumer_hash != current file (local changes). + final repoRoot = tempDir.path; + + // pubspec with path dep to runtime_ci_tooling (different name to avoid self-dep) + File(p.join(repoRoot, 'pubspec.yaml')).writeAsStringSync(''' +name: update_test_consumer +version: 0.0.0 +environment: + sdk: ^3.9.0 +dependencies: + runtime_ci_tooling: + path: $packagePath +'''); + + // .runtime_ci/config.json (repository.name must match pubspec for RepoUtils) + Directory(p.join(repoRoot, '.runtime_ci')).createSync(recursive: true); + File(p.join(repoRoot, '.runtime_ci', 'config.json')).writeAsStringSync( + json.encode({ + 'repository': {'name': 'update_test_consumer', 'owner': 'test'}, + }), + ); + + // Consumer file with local modifications (differs from template) + const localContent = + '{"_comment":"local customization","model":{"maxSessionTurns":99}}\n'; + Directory(p.join(repoRoot, '.gemini')).createSync(recursive: true); + File( + p.join(repoRoot, '.gemini', 'settings.json'), + ).writeAsStringSync(localContent); + + // Template versions: consumer_hash = hash of "original" content so we + // detect local changes. hash = old template hash so template appears + // changed (we enter update path, not "up to date"). + final originalContent = '{"_comment":"original"}\n'; + final originalHash = sha256 + .convert(originalContent.codeUnits) + .toString(); + const oldTemplateHash = + '0000000000000000000000000000000000000000000000000000000000000001'; + + File( + p.join(repoRoot, '.runtime_ci', 'template_versions.json'), + ).writeAsStringSync( + json.encode({ + 'tooling_version': '0.0.0', + 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'templates': { + 'gemini_settings': { + 'hash': oldTemplateHash, + 'consumer_hash': originalHash, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + }, + }), + ); + + // Resolve dependencies so package_config.json exists + final pubGet = await Process.run('dart', [ + 'pub', + 'get', + ], workingDirectory: repoRoot); + expect(pubGet.exitCode, equals(0), reason: 'pub get must succeed'); + + // Run manage_cicd update --diff --templates from temp repo + final result = await Process.run( + 'dart', + [ + 'run', + 'runtime_ci_tooling:manage_cicd', + 'update', + '--diff', + '--templates', + ], + workingDirectory: repoRoot, + runInShell: false, + environment: {'PATH': Platform.environment['PATH'] ?? ''}, + ); + + final stdout = result.stdout as String; + final stderr = result.stderr as String; + final combined = '$stdout\n$stderr'; + + expect( + combined, + contains('local customizations detected'), + reason: 'Should warn about local customizations', + ); + expect( + combined, + contains('[diff]'), + reason: + 'PR #36: --diff must emit diff preview on local-customization skip path', + ); + expect( + combined, + contains('.gemini/settings.json'), + reason: 'Diff should reference the overwritable file path', + ); + }, + ); + }); +} diff --git a/test/workflow_generator_test.dart b/test/workflow_generator_test.dart index af11d18..7a5d76c 100644 --- a/test/workflow_generator_test.dart +++ b/test/workflow_generator_test.dart @@ -39,7 +39,10 @@ String _readToolingVersionFromPubspec() { throw StateError('pubspec.yaml not found in current working directory'); } final content = pubspec.readAsStringSync(); - final match = RegExp(r'^version:\s*([^\s]+)\s*$', multiLine: true).firstMatch(content); + final match = RegExp( + r'^version:\s*([^\s]+)\s*$', + multiLine: true, + ).firstMatch(content); if (match == null) { throw StateError('Could not parse version from pubspec.yaml'); } @@ -54,65 +57,99 @@ void main() { // ---- dart_sdk ---- group('dart_sdk', () { test('missing dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({'features': {}}); + final errors = WorkflowGenerator.validate({ + 'features': {}, + }); expect(errors, contains('ci.dart_sdk is required')); }); test('null dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': null, 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': null, + 'features': {}, + }); expect(errors, contains('ci.dart_sdk is required')); }); test('non-string dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': 42, 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': 42, + 'features': {}, + }); expect(errors, anyElement(contains('must be a string'))); }); test('empty-string dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': '', 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': '', + 'features': {}, + }); expect(errors, anyElement(contains('non-empty'))); }); test('whitespace-only dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': ' ', 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': ' ', + 'features': {}, + }); // After trim the string is empty expect(errors, anyElement(contains('non-empty'))); }); test('dart_sdk with leading/trailing whitespace produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': ' 3.9.2 ', 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': ' 3.9.2 ', + 'features': {}, + }); expect(errors, anyElement(contains('whitespace'))); }); test('dart_sdk with trailing newline triggers whitespace error', () { // A trailing \n makes trimmed != sdk, so the whitespace check fires first. - final errors = WorkflowGenerator.validate({'dart_sdk': '3.9.2\n', 'features': {}}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': '3.9.2\n', + 'features': {}, + }); expect(errors, anyElement(contains('whitespace'))); }); - test('dart_sdk with embedded tab (after trim is identity) triggers newlines/tabs error', () { - // A tab in the middle: trim() has no effect but the regex catches it. - final errors = WorkflowGenerator.validate({'dart_sdk': '3.9\t.2', 'features': {}}); - expect(errors, anyElement(contains('newlines/tabs'))); - }); + test( + 'dart_sdk with embedded tab (after trim is identity) triggers newlines/tabs error', + () { + // A tab in the middle: trim() has no effect but the regex catches it. + final errors = WorkflowGenerator.validate({ + 'dart_sdk': '3.9\t.2', + 'features': {}, + }); + expect(errors, anyElement(contains('newlines/tabs'))); + }, + ); test('valid semver dart_sdk passes', () { - final errors = WorkflowGenerator.validate(_validConfig(dartSdk: '3.9.2')); + final errors = WorkflowGenerator.validate( + _validConfig(dartSdk: '3.9.2'), + ); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('valid semver with pre-release passes', () { - final errors = WorkflowGenerator.validate(_validConfig(dartSdk: '3.10.0-beta.1')); + final errors = WorkflowGenerator.validate( + _validConfig(dartSdk: '3.10.0-beta.1'), + ); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('channel "stable" passes', () { - final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'stable')); + final errors = WorkflowGenerator.validate( + _validConfig(dartSdk: 'stable'), + ); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('channel "beta" passes', () { - final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'beta')); + final errors = WorkflowGenerator.validate( + _validConfig(dartSdk: 'beta'), + ); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); @@ -122,7 +159,9 @@ void main() { }); test('invalid dart_sdk like "latest" produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'latest')); + final errors = WorkflowGenerator.validate( + _validConfig(dartSdk: 'latest'), + ); expect(errors, anyElement(contains('channel'))); }); @@ -140,7 +179,10 @@ void main() { }); test('non-map features produces error', () { - final errors = WorkflowGenerator.validate({'dart_sdk': '3.9.2', 'features': 'not_a_map'}); + final errors = WorkflowGenerator.validate({ + 'dart_sdk': '3.9.2', + 'features': 'not_a_map', + }); expect(errors, anyElement(contains('features must be an object'))); }); @@ -187,12 +229,16 @@ void main() { // ---- platforms ---- group('platforms', () { test('non-list platforms produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(platforms: null)..['platforms'] = 'ubuntu'); + final errors = WorkflowGenerator.validate( + _validConfig(platforms: null)..['platforms'] = 'ubuntu', + ); expect(errors, anyElement(contains('platforms must be an array'))); }); test('unknown platform entry produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu', 'solaris'])); + final errors = WorkflowGenerator.validate( + _validConfig(platforms: ['ubuntu', 'solaris']), + ); expect(errors, anyElement(contains('invalid platform "solaris"'))); }); @@ -204,12 +250,16 @@ void main() { }); test('valid single platform passes', () { - final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu'])); + final errors = WorkflowGenerator.validate( + _validConfig(platforms: ['ubuntu']), + ); expect(errors.where((e) => e.contains('platforms')), isEmpty); }); test('valid multi-platform passes', () { - final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu', 'macos', 'windows'])); + final errors = WorkflowGenerator.validate( + _validConfig(platforms: ['ubuntu', 'macos', 'windows']), + ); expect(errors.where((e) => e.contains('platforms')), isEmpty); }); @@ -234,42 +284,61 @@ void main() { }); test('valid secrets map passes', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'SOME_SECRET'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'API_KEY': 'SOME_SECRET'}), + ); expect(errors.where((e) => e.contains('secrets')), isEmpty); }); test('secrets key with hyphen produces error (unsafe identifier)', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API-KEY': 'SOME_SECRET'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'API-KEY': 'SOME_SECRET'}), + ); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets key starting with digit produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'1API_KEY': 'SOME_SECRET'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'1API_KEY': 'SOME_SECRET'}), + ); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets value with hyphen produces error (unsafe secret name)', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'SOME-SECRET'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'API_KEY': 'SOME-SECRET'}), + ); expect(errors, anyElement(contains('safe secret name'))); }); test('secrets key and value with underscore pass', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'MY_SECRET_NAME'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'API_KEY': 'MY_SECRET_NAME'}), + ); expect(errors.where((e) => e.contains('secrets')), isEmpty); }); - test('secrets key with leading underscore produces error (must start with uppercase letter)', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'_API_KEY': 'MY_SECRET'})); - expect(errors, anyElement(contains('safe identifier'))); - }); + test( + 'secrets key with leading underscore produces error (must start with uppercase letter)', + () { + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'_API_KEY': 'MY_SECRET'}), + ); + expect(errors, anyElement(contains('safe identifier'))); + }, + ); test('secrets key with lowercase produces error (uppercase only)', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'api_key': 'MY_SECRET'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'api_key': 'MY_SECRET'}), + ); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets value with lowercase produces error (uppercase only)', () { - final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'my_secret'})); + final errors = WorkflowGenerator.validate( + _validConfig(secrets: {'API_KEY': 'my_secret'}), + ); expect(errors, anyElement(contains('safe secret name'))); }); }); @@ -290,12 +359,18 @@ void main() { test('valid pat passes', () { final errors = WorkflowGenerator.validate(_validConfig(pat: 'MY_PAT')); - expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); + expect( + errors.where((e) => e.contains('personal_access_token_secret')), + isEmpty, + ); }); test('null pat is fine (optional, defaults to GITHUB_TOKEN)', () { final errors = WorkflowGenerator.validate(_validConfig()); - expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); + expect( + errors.where((e) => e.contains('personal_access_token_secret')), + isEmpty, + ); }); test('pat with hyphen produces error (unsafe identifier)', () { @@ -304,19 +379,31 @@ void main() { }); test('pat with special chars produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(pat: r'MY_PAT$')); + final errors = WorkflowGenerator.validate( + _validConfig(pat: r'MY_PAT$'), + ); expect(errors, anyElement(contains('safe identifier'))); }); test('pat GITHUB_TOKEN passes', () { - final errors = WorkflowGenerator.validate(_validConfig(pat: 'GITHUB_TOKEN')); - expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); + final errors = WorkflowGenerator.validate( + _validConfig(pat: 'GITHUB_TOKEN'), + ); + expect( + errors.where((e) => e.contains('personal_access_token_secret')), + isEmpty, + ); }); - test('pat with leading underscore produces error (must start with uppercase letter)', () { - final errors = WorkflowGenerator.validate(_validConfig(pat: '_MY_PAT')); - expect(errors, anyElement(contains('safe identifier'))); - }); + test( + 'pat with leading underscore produces error (must start with uppercase letter)', + () { + final errors = WorkflowGenerator.validate( + _validConfig(pat: '_MY_PAT'), + ); + expect(errors, anyElement(contains('safe identifier'))); + }, + ); test('pat with lowercase produces error (uppercase only)', () { final errors = WorkflowGenerator.validate(_validConfig(pat: 'my_pat')); @@ -339,22 +426,30 @@ void main() { }); test('git_orgs entry with whitespace produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [' open-runtime '])); + final errors = WorkflowGenerator.validate( + _validConfig(gitOrgs: [' open-runtime ']), + ); expect(errors, anyElement(contains('leading/trailing whitespace'))); }); test('git_orgs entry with unsupported characters produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open/runtime'])); + final errors = WorkflowGenerator.validate( + _validConfig(gitOrgs: ['open/runtime']), + ); expect(errors, anyElement(contains('unsupported characters'))); }); test('git_orgs duplicate entries produce error', () { - final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'open-runtime'])); + final errors = WorkflowGenerator.validate( + _validConfig(gitOrgs: ['open-runtime', 'open-runtime']), + ); expect(errors, anyElement(contains('duplicate org "open-runtime"'))); }); test('valid git_orgs list passes', () { - final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme'])); + final errors = WorkflowGenerator.validate( + _validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme']), + ); expect(errors.where((e) => e.contains('git_orgs')), isEmpty); }); }); @@ -362,7 +457,9 @@ void main() { // ---- line_length ---- group('line_length', () { test('non-numeric line_length produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: true)); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: true), + ); expect(errors, anyElement(contains('line_length'))); }); @@ -372,7 +469,9 @@ void main() { }); test('string line_length passes', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '120')); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '120'), + ); expect(errors.where((e) => e.contains('line_length')), isEmpty); }); @@ -382,7 +481,9 @@ void main() { }); test('string line_length "abc" produces error (must be digits only)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: 'abc')); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: 'abc'), + ); expect(errors, anyElement(contains('digits only'))); }); @@ -391,33 +492,54 @@ void main() { expect(errors, anyElement(contains('must not be empty'))); }); - test('string line_length "+120" produces error (digits only, no sign)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '+120')); - expect(errors, anyElement(contains('digits only'))); - }); + test( + 'string line_length "+120" produces error (digits only, no sign)', + () { + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '+120'), + ); + expect(errors, anyElement(contains('digits only'))); + }, + ); - test('string line_length "-120" produces error (digits only, no sign)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '-120')); - expect(errors, anyElement(contains('digits only'))); - }); + test( + 'string line_length "-120" produces error (digits only, no sign)', + () { + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '-120'), + ); + expect(errors, anyElement(contains('digits only'))); + }, + ); - test('string line_length with leading/trailing whitespace produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: ' 120 ')); - expect(errors, anyElement(contains('whitespace'))); - }); + test( + 'string line_length with leading/trailing whitespace produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: ' 120 '), + ); + expect(errors, anyElement(contains('whitespace'))); + }, + ); test('string line_length with embedded newline produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '12\n0')); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '12\n0'), + ); expect(errors, anyElement(contains('newlines or control'))); }); test('string line_length "0" produces error (out of range)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '0')); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '0'), + ); expect(errors, anyElement(contains('between 1 and 10000'))); }); test('string line_length "10001" produces error (out of range)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: '10001')); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: '10001'), + ); expect(errors, anyElement(contains('between 1 and 10000'))); }); @@ -427,7 +549,9 @@ void main() { }); test('int line_length 10001 produces error (out of range)', () { - final errors = WorkflowGenerator.validate(_validConfig(lineLength: 10001)); + final errors = WorkflowGenerator.validate( + _validConfig(lineLength: 10001), + ); expect(errors, anyElement(contains('between 1 and 10000'))); }); }); @@ -437,19 +561,30 @@ void main() { test('int artifact_retention_days passes', () { final config = _validConfig()..['artifact_retention_days'] = 14; final errors = WorkflowGenerator.validate(config); - expect(errors.where((e) => e.contains('artifact_retention_days')), isEmpty); + expect( + errors.where((e) => e.contains('artifact_retention_days')), + isEmpty, + ); }); test('string artifact_retention_days passes', () { final config = _validConfig()..['artifact_retention_days'] = '30'; final errors = WorkflowGenerator.validate(config); - expect(errors.where((e) => e.contains('artifact_retention_days')), isEmpty); + expect( + errors.where((e) => e.contains('artifact_retention_days')), + isEmpty, + ); }); test('artifact_retention_days empty string produces error', () { final config = _validConfig()..['artifact_retention_days'] = ''; final errors = WorkflowGenerator.validate(config); - expect(errors, anyElement(contains('artifact_retention_days string must not be empty'))); + expect( + errors, + anyElement( + contains('artifact_retention_days string must not be empty'), + ), + ); }); test('artifact_retention_days above 90 produces error', () { @@ -469,8 +604,13 @@ void main() { }); test('sub_packages entry that is not a map produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(subPackages: ['just_a_string'])); - expect(errors, anyElement(contains('sub_packages entries must be objects'))); + final errors = WorkflowGenerator.validate( + _validConfig(subPackages: ['just_a_string']), + ); + expect( + errors, + anyElement(contains('sub_packages entries must be objects')), + ); }); test('sub_packages with missing name produces error', () { @@ -495,16 +635,22 @@ void main() { expect(errors, anyElement(contains('name must be a non-empty string'))); }); - test('sub_packages with name containing unsupported characters produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo bar', 'path': 'packages/foo'}, - ], - ), - ); - expect(errors, anyElement(contains('name contains unsupported characters'))); - }); + test( + 'sub_packages with name containing unsupported characters produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo bar', 'path': 'packages/foo'}, + ], + ), + ); + expect( + errors, + anyElement(contains('name contains unsupported characters')), + ); + }, + ); test('sub_packages with missing path produces error', () { final errors = WorkflowGenerator.validate( @@ -528,16 +674,22 @@ void main() { expect(errors, anyElement(contains('path must be a non-empty string'))); }); - test('sub_packages path with directory traversal (..) produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': '../../../etc/passwd'}, - ], - ), - ); - expect(errors, anyElement(contains('must not traverse outside the repo'))); - }); + test( + 'sub_packages path with directory traversal (..) produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': '../../../etc/passwd'}, + ], + ), + ); + expect( + errors, + anyElement(contains('must not traverse outside the repo')), + ); + }, + ); test('sub_packages path with embedded traversal produces error', () { final errors = WorkflowGenerator.validate( @@ -547,7 +699,10 @@ void main() { ], ), ); - expect(errors, anyElement(contains('must not traverse outside the repo'))); + expect( + errors, + anyElement(contains('must not traverse outside the repo')), + ); }); test('sub_packages absolute path produces error', () { @@ -616,27 +771,38 @@ void main() { expect(errors, anyElement(contains('unsupported characters'))); }); - test('sub_packages path with leading/trailing whitespace produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': ' packages/foo '}, - ], - ), - ); - expect(errors, anyElement(contains('whitespace'))); - }); + test( + 'sub_packages path with leading/trailing whitespace produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': ' packages/foo '}, + ], + ), + ); + expect(errors, anyElement(contains('whitespace'))); + }, + ); - test('sub_packages name with leading/trailing whitespace produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': ' foo ', 'path': 'packages/foo'}, - ], - ), - ); - expect(errors, anyElement(contains('name must not have leading/trailing whitespace'))); - }); + test( + 'sub_packages name with leading/trailing whitespace produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': ' foo ', 'path': 'packages/foo'}, + ], + ), + ); + expect( + errors, + anyElement( + contains('name must not have leading/trailing whitespace'), + ), + ); + }, + ); test('sub_packages path with trailing tab triggers whitespace error', () { // Trailing \t means trimmed != value, so the whitespace check fires first. @@ -650,17 +816,20 @@ void main() { expect(errors, anyElement(contains('whitespace'))); }); - test('sub_packages path with embedded tab triggers newlines/tabs error', () { - // Embedded tab: trim() is identity, so newlines/tabs check catches it. - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': 'packages/f\too'}, - ], - ), - ); - expect(errors, anyElement(contains('newlines/tabs'))); - }); + test( + 'sub_packages path with embedded tab triggers newlines/tabs error', + () { + // Embedded tab: trim() is identity, so newlines/tabs check catches it. + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': 'packages/f\too'}, + ], + ), + ); + expect(errors, anyElement(contains('newlines/tabs'))); + }, + ); test('sub_packages duplicate name produces error', () { final errors = WorkflowGenerator.validate( @@ -674,17 +843,20 @@ void main() { expect(errors, anyElement(contains('duplicate name "foo"'))); }); - test('sub_packages duplicate path (after normalization) produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': 'packages/foo'}, - {'name': 'bar', 'path': 'packages/./foo'}, - ], - ), - ); - expect(errors, anyElement(contains('duplicate path'))); - }); + test( + 'sub_packages duplicate path (after normalization) produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': 'packages/foo'}, + {'name': 'bar', 'path': 'packages/./foo'}, + ], + ), + ); + expect(errors, anyElement(contains('duplicate path'))); + }, + ); test('valid sub_packages passes', () { final errors = WorkflowGenerator.validate( @@ -710,52 +882,79 @@ void main() { final config = _validConfig(); config['runner_overrides'] = 'invalid'; final errors = WorkflowGenerator.validate(config); - expect(errors, anyElement(contains('runner_overrides must be an object'))); + expect( + errors, + anyElement(contains('runner_overrides must be an object')), + ); }); test('runner_overrides with invalid platform key produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'solaris': 'my-runner'})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'solaris': 'my-runner'}), + ); expect(errors, anyElement(contains('invalid platform key "solaris"'))); }); test('runner_overrides with empty string value produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': ''})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': ''}), + ); expect(errors, anyElement(contains('must be a non-empty string'))); }); - test('runner_overrides value with surrounding whitespace produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': ' custom-runner '})); - expect(errors, anyElement(contains('leading/trailing whitespace'))); - }); + test( + 'runner_overrides value with surrounding whitespace produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': ' custom-runner '}), + ); + expect(errors, anyElement(contains('leading/trailing whitespace'))); + }, + ); test('valid runner_overrides passes', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'custom-runner-label'})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': 'custom-runner-label'}), + ); expect(errors.where((e) => e.contains('runner_overrides')), isEmpty); }); test('runner_overrides value with newline produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner\nlabel'})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': 'runner\nlabel'}), + ); expect(errors, anyElement(contains('newlines, control chars'))); }); test('runner_overrides value with tab produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner\tlabel'})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': 'runner\tlabel'}), + ); expect(errors, anyElement(contains('newlines, control chars'))); }); - test('runner_overrides value with YAML-injection char produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner:label'})); - expect(errors, anyElement(contains('unsafe YAML chars'))); - }); + test( + 'runner_overrides value with YAML-injection char produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': 'runner:label'}), + ); + expect(errors, anyElement(contains('unsafe YAML chars'))); + }, + ); test('runner_overrides value with dollar sign produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': r'runner$label'})); + final errors = WorkflowGenerator.validate( + _validConfig(runnerOverrides: {'ubuntu': r'runner$label'}), + ); expect(errors, anyElement(contains('unsafe YAML chars'))); }); test('runner_overrides value with hyphen and dot passes', () { final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': 'runtime-ubuntu-24.04-x64-256gb'}), + _validConfig( + runnerOverrides: {'ubuntu': 'runtime-ubuntu-24.04-x64-256gb'}, + ), ); expect(errors.where((e) => e.contains('runner_overrides')), isEmpty); }); @@ -776,49 +975,68 @@ void main() { }); test('web_test.concurrency non-int produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 'fast'})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': 'fast'}), + ); expect(errors, anyElement(contains('concurrency must be an integer'))); }); test('web_test.concurrency zero produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 0})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': 0}), + ); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency negative produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': -1})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': -1}), + ); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency exceeds upper bound produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 33})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': 33}), + ); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency double/float produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 3.14})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': 3.14}), + ); expect(errors, anyElement(contains('concurrency must be an integer'))); }); test('web_test.concurrency valid int passes', () { final errors = WorkflowGenerator.validate( - _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}, webTest: {'concurrency': 4}), + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + webTest: {'concurrency': 4}, + ), ); expect(errors.where((e) => e.contains('web_test')), isEmpty); }); test('web_test.concurrency at upper bound (32) passes', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 32})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'concurrency': 32}), + ); expect(errors.where((e) => e.contains('concurrency')), isEmpty); }); test('web_test.concurrency null is fine (defaults to 1)', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {}), + ); expect(errors.where((e) => e.contains('concurrency')), isEmpty); }); test('web_test.paths non-list produces error', () { - final errors = WorkflowGenerator.validate(_validConfig(webTest: {'paths': 'not_a_list'})); + final errors = WorkflowGenerator.validate( + _validConfig(webTest: {'paths': 'not_a_list'}), + ); expect(errors, anyElement(contains('paths must be an array'))); }); @@ -852,42 +1070,57 @@ void main() { }, ), ); - expect(errors, anyElement(contains('must not traverse outside the repo'))); - }); - - test('web_test.paths with embedded traversal (test/web/../../../etc/passwd) produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - webTest: { - 'paths': ['test/web/../../../etc/passwd'], - }, - ), - ); - expect(errors, anyElement(contains('must not traverse outside the repo'))); - }); + expect( + errors, + anyElement(contains('must not traverse outside the repo')), + ); + }); + + test( + 'web_test.paths with embedded traversal (test/web/../../../etc/passwd) produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + webTest: { + 'paths': ['test/web/../../../etc/passwd'], + }, + ), + ); + expect( + errors, + anyElement(contains('must not traverse outside the repo')), + ); + }, + ); - test('web_test.paths with shell metacharacters (\$(curl evil)) produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': [r'$(curl evil)'], - }, - ), - ); - expect(errors, anyElement(contains('unsupported characters'))); - }); + test( + 'web_test.paths with shell metacharacters (\$(curl evil)) produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': [r'$(curl evil)'], + }, + ), + ); + expect(errors, anyElement(contains('unsupported characters'))); + }, + ); - test('web_test.paths with shell metacharacters (; rm -rf /) produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': ['; rm -rf /'], - }, - ), - ); - expect(errors, anyElement(contains('unsupported characters'))); - }); + test( + 'web_test.paths with shell metacharacters (; rm -rf /) produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': ['; rm -rf /'], + }, + ), + ); + expect(errors, anyElement(contains('unsupported characters'))); + }, + ); test('web_test.paths with single quote produces error', () { final errors = WorkflowGenerator.validate( @@ -989,16 +1222,22 @@ void main() { expect(errors, anyElement(contains('newlines/tabs'))); }); - test('web_test.paths with embedded traversal that escapes repo produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': ['test/../../../etc/passwd'], - }, - ), - ); - expect(errors, anyElement(contains('must not traverse outside the repo'))); - }); + test( + 'web_test.paths with embedded traversal that escapes repo produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': ['test/../../../etc/passwd'], + }, + ), + ); + expect( + errors, + anyElement(contains('must not traverse outside the repo')), + ); + }, + ); test('web_test.paths with embedded .. that stays in repo is fine', () { // test/web/../../etc/passwd normalizes to etc/passwd (still inside repo) @@ -1093,7 +1332,10 @@ void main() { test('empty web_test.paths list is fine', () { final errors = WorkflowGenerator.validate( - _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}, webTest: {'paths': []}), + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + webTest: {'paths': []}, + ), ); expect(errors.where((e) => e.contains('web_test')), isEmpty); }); @@ -1118,42 +1360,65 @@ void main() { expect(errors, anyElement(contains('unknown key "concurreny"'))); }); - test('cross-validation: web_test config present but feature disabled produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': false}, - webTest: { - 'concurrency': 2, - 'paths': ['test/web/'], - }, - ), - ); - expect(errors, anyElement(contains('web_test config is present but ci.features.web_test is not enabled'))); - }); - - test('cross-validation: web_test feature enabled but config wrong type produces error', () { - final config = _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}); - config['web_test'] = 'yes'; - final errors = WorkflowGenerator.validate(config); - expect(errors, anyElement(contains('web_test must be an object'))); - }); + test( + 'cross-validation: web_test config present but feature disabled produces error', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': false}, + webTest: { + 'concurrency': 2, + 'paths': ['test/web/'], + }, + ), + ); + expect( + errors, + anyElement( + contains( + 'web_test config is present but ci.features.web_test is not enabled', + ), + ), + ); + }, + ); - test('cross-validation: web_test feature enabled with no config object (null) is allowed, uses defaults', () { - final errors = WorkflowGenerator.validate( - _validConfig( + test( + 'cross-validation: web_test feature enabled but config wrong type produces error', + () { + final config = _validConfig( features: {'proto': false, 'lfs': false, 'web_test': true}, - // webTest: null (omitted) — config is optional when feature is enabled - ), - ); - expect(errors.where((e) => e.contains('web_test')), isEmpty); - }); + ); + config['web_test'] = 'yes'; + final errors = WorkflowGenerator.validate(config); + expect(errors, anyElement(contains('web_test must be an object'))); + }, + ); - test('cross-validation: web_test feature enabled with explicit null config is allowed', () { - final config = _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}); - config['web_test'] = null; - final errors = WorkflowGenerator.validate(config); - expect(errors.where((e) => e.contains('web_test')), isEmpty); - }); + test( + 'cross-validation: web_test feature enabled with no config object (null) is allowed, uses defaults', + () { + final errors = WorkflowGenerator.validate( + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + // webTest: null (omitted) — config is optional when feature is enabled + ), + ); + expect(errors.where((e) => e.contains('web_test')), isEmpty); + }, + ); + + test( + 'cross-validation: web_test feature enabled with explicit null config is allowed', + () { + final config = _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + ); + config['web_test'] = null; + final errors = WorkflowGenerator.validate(config); + expect(errors.where((e) => e.contains('web_test')), isEmpty); + }, + ); }); // ---- fully valid config produces no errors ---- @@ -1211,7 +1476,9 @@ void main() { test('returns null when config.json exists but has no "ci" key', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File('${configDir.path}/config.json').writeAsStringSync(json.encode({'repo_name': 'test_repo'})); + File( + '${configDir.path}/config.json', + ).writeAsStringSync(json.encode({'repo_name': 'test_repo'})); final result = WorkflowGenerator.loadCiConfig(tempDir.path); expect(result, isNull); }); @@ -1235,19 +1502,35 @@ void main() { test('throws StateError on malformed JSON', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File('${configDir.path}/config.json').writeAsStringSync('{ not valid json'); + File( + '${configDir.path}/config.json', + ).writeAsStringSync('{ not valid json'); expect( () => WorkflowGenerator.loadCiConfig(tempDir.path), - throwsA(isA().having((e) => e.message, 'message', contains('Malformed JSON'))), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Malformed JSON'), + ), + ), ); }); test('throws StateError when "ci" is not a Map', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File('${configDir.path}/config.json').writeAsStringSync(json.encode({'ci': 'not_a_map'})); + File( + '${configDir.path}/config.json', + ).writeAsStringSync(json.encode({'ci': 'not_a_map'})); expect( () => WorkflowGenerator.loadCiConfig(tempDir.path), - throwsA(isA().having((e) => e.message, 'message', contains('object'))), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('object'), + ), + ), ); }); @@ -1258,7 +1541,10 @@ void main() { 'ci': [1, 2, 3], }), ); - expect(() => WorkflowGenerator.loadCiConfig(tempDir.path), throwsA(isA())); + expect( + () => WorkflowGenerator.loadCiConfig(tempDir.path), + throwsA(isA()), + ); }); }); @@ -1295,37 +1581,58 @@ void main() { } // ---- render() validation guard (defense-in-depth) ---- - test('render throws StateError when config is invalid (missing dart_sdk)', () { - final gen = WorkflowGenerator(ciConfig: {'features': {}}, toolingVersion: '0.0.0-test'); - expect( - () => gen.render(), - throwsA( - isA().having( - (e) => e.message, - 'message', - allOf(contains('Cannot render with invalid config'), contains('dart_sdk')), + test( + 'render throws StateError when config is invalid (missing dart_sdk)', + () { + final gen = WorkflowGenerator( + ciConfig: {'features': {}}, + toolingVersion: '0.0.0-test', + ); + expect( + () => gen.render(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('Cannot render with invalid config'), + contains('dart_sdk'), + ), + ), ), - ), - ); - }); + ); + }, + ); - test('render throws StateError when config has multiple validation errors', () { - final gen = WorkflowGenerator(ciConfig: {}, toolingVersion: '0.0.0-test'); - expect( - () => gen.render(), - throwsA( - isA().having( - (e) => e.message, - 'message', - allOf(contains('Cannot render with invalid config'), contains('dart_sdk'), contains('features')), + test( + 'render throws StateError when config has multiple validation errors', + () { + final gen = WorkflowGenerator( + ciConfig: {}, + toolingVersion: '0.0.0-test', + ); + expect( + () => gen.render(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('Cannot render with invalid config'), + contains('dart_sdk'), + contains('features'), + ), + ), ), - ), - ); - }); + ); + }, + ); test('render throws StateError when config has invalid web_test type', () { final gen = WorkflowGenerator( - ciConfig: _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true})..['web_test'] = 'yes', + ciConfig: _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + )..['web_test'] = 'yes', toolingVersion: '0.0.0-test', ); expect( @@ -1334,14 +1641,20 @@ void main() { isA().having( (e) => e.message, 'message', - allOf(contains('Cannot render with invalid config'), contains('web_test must be an object')), + allOf( + contains('Cannot render with invalid config'), + contains('web_test must be an object'), + ), ), ), ); }); test('render succeeds on valid config', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: false), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: false), + toolingVersion: '0.0.0-test', + ); final rendered = gen.render(); expect(rendered, isNotEmpty); final parsed = loadYaml(rendered) as YamlMap; @@ -1356,22 +1669,34 @@ void main() { }); test('web_test=false: rendered output does not contain web-test job', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: false), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: false), + toolingVersion: '0.0.0-test', + ); final rendered = gen.render(); expect(rendered, isNot(contains('web-test:'))); expect(rendered, isNot(contains('dart test -p chrome'))); }); - test('web_test=true with omitted config uses default concurrency and no explicit paths', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); - final rendered = gen.render(); - expect(rendered, contains('web-test:')); - expect(rendered, contains('dart test -p chrome')); - expect(rendered, contains('--concurrency=1')); - expect(rendered, contains('Enable Chrome user namespaces on Ubuntu')); - expect(rendered, contains('kernel.apparmor_restrict_unprivileged_userns=0')); - expect(rendered, isNot(contains("'test/"))); - }); + test( + 'web_test=true with omitted config uses default concurrency and no explicit paths', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('web-test:')); + expect(rendered, contains('dart test -p chrome')); + expect(rendered, contains('--concurrency=1')); + expect(rendered, contains('Enable Chrome user namespaces on Ubuntu')); + expect( + rendered, + contains('kernel.apparmor_restrict_unprivileged_userns=0'), + ); + expect(rendered, isNot(contains("'test/"))); + }, + ); test('web_test=true with paths: rendered output includes path args', () { final gen = WorkflowGenerator( @@ -1390,18 +1715,27 @@ void main() { expect(rendered, contains('-- \'test/web/foo_test.dart\'')); }); - test('web_test=true with concurrency at upper bound (32): rendered output uses 32', () { + test( + 'web_test=true with concurrency at upper bound (32): rendered output uses 32', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + webTest: true, + webTestConfig: {'concurrency': 32}, + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('--concurrency=32')); + }, + ); + + test('rendered output parses as valid YAML with jobs map', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, webTestConfig: {'concurrency': 32}), + ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); - expect(rendered, contains('--concurrency=32')); - }); - - test('rendered output parses as valid YAML with jobs map', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final rendered = gen.render(); final parsed = loadYaml(rendered) as YamlMap; expect(parsed.containsKey('name'), isTrue); @@ -1424,58 +1758,85 @@ void main() { expect('${firstStep['uses']}', contains('actions/checkout')); }); - test('rendered workflow stays in sync with committed .github/workflows/ci.yaml', () { - final ciConfig = WorkflowGenerator.loadCiConfig(Directory.current.path); - expect(ciConfig, isNotNull, reason: 'Repository CI config must be present'); - - final goldenPath = '.github/workflows/ci.yaml'; - final goldenFile = File(goldenPath); - expect(goldenFile.existsSync(), isTrue, reason: 'Committed workflow golden must exist'); + test( + 'rendered workflow stays in sync with committed .github/workflows/ci.yaml', + () { + final ciConfig = WorkflowGenerator.loadCiConfig(Directory.current.path); + expect( + ciConfig, + isNotNull, + reason: 'Repository CI config must be present', + ); - final existingContent = goldenFile.readAsStringSync(); - final toolingVersion = _readToolingVersionFromPubspec(); - final rendered = WorkflowGenerator( - ciConfig: ciConfig!, - toolingVersion: toolingVersion, - ).render(existingContent: existingContent); + final goldenPath = '.github/workflows/ci.yaml'; + final goldenFile = File(goldenPath); + expect( + goldenFile.existsSync(), + isTrue, + reason: 'Committed workflow golden must exist', + ); - String normalize(String input) => '${input.replaceAll('\r\n', '\n').trimRight()}\n'; + final existingContent = goldenFile.readAsStringSync(); + final toolingVersion = _readToolingVersionFromPubspec(); + final rendered = WorkflowGenerator( + ciConfig: ciConfig!, + toolingVersion: toolingVersion, + ).render(existingContent: existingContent); - expect( - normalize(rendered), - equals(normalize(existingContent)), - reason: 'Generated workflow drifted from committed file. Re-run workflow generation and commit updated output.', - ); - }); + String normalize(String input) => + '${input.replaceAll('\r\n', '\n').trimRight()}\n'; - test('managed_test: upload step uses success() || failure() not cancelled', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('success() || failure()')); - expect(rendered, isNot(contains('always()'))); - }); + expect( + normalize(rendered), + equals(normalize(existingContent)), + reason: + 'Generated workflow drifted from committed file. Re-run workflow generation and commit updated output.', + ); + }, + ); - test('managed_test: Test step has pipefail and tee for correct exit propagation', () { - // Single-platform and multi-platform must share identical test step - // structure: pipefail ensures test exit code propagates through tee. - final single = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu']), - toolingVersion: '0.0.0-test', - ).render(); - final multi = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu', 'macos']), - toolingVersion: '0.0.0-test', - ).render(); - for (final rendered in [single, multi]) { - expect(rendered, contains('set -o pipefail')); - expect(rendered, contains('tee "')); - expect(rendered, contains('console.log"')); - expect(rendered, contains('manage_cicd test 2>&1')); - } - }); + test( + 'managed_test: upload step uses success() || failure() not cancelled', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('success() || failure()')); + expect(rendered, isNot(contains('always()'))); + }, + ); + + test( + 'managed_test: Test step has pipefail and tee for correct exit propagation', + () { + // Single-platform and multi-platform must share identical test step + // structure: pipefail ensures test exit code propagates through tee. + final single = WorkflowGenerator( + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + platforms: ['ubuntu'], + ), + toolingVersion: '0.0.0-test', + ).render(); + final multi = WorkflowGenerator( + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + platforms: ['ubuntu', 'macos'], + ), + toolingVersion: '0.0.0-test', + ).render(); + for (final rendered in [single, multi]) { + expect(rendered, contains('set -o pipefail')); + expect(rendered, contains('tee "')); + expect(rendered, contains('console.log"')); + expect(rendered, contains('manage_cicd test 2>&1')); + } + }, + ); test('feature flags render expected snippets', () { final cases = >[ @@ -1483,8 +1844,14 @@ void main() { {'feature': 'lfs', 'snippet': 'lfs: true'}, {'feature': 'format_check', 'snippet': 'auto-format:'}, {'feature': 'analysis_cache', 'snippet': 'Cache Dart analysis'}, - {'feature': 'managed_analyze', 'snippet': 'runtime_ci_tooling:manage_cicd analyze'}, - {'feature': 'managed_test', 'snippet': 'runtime_ci_tooling:manage_cicd test'}, + { + 'feature': 'managed_analyze', + 'snippet': 'runtime_ci_tooling:manage_cicd analyze', + }, + { + 'feature': 'managed_test', + 'snippet': 'runtime_ci_tooling:manage_cicd test', + }, {'feature': 'build_runner', 'snippet': 'Run build_runner'}, ]; @@ -1496,13 +1863,19 @@ void main() { toolingVersion: '0.0.0-test', ); final rendered = gen.render(); - expect(rendered, contains(snippet), reason: 'Feature "$feature" should render "$snippet".'); + expect( + rendered, + contains(snippet), + reason: 'Feature "$feature" should render "$snippet".', + ); } }); test('build_runner=false omits build_runner step', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'build_runner': false}), + ciConfig: _minimalValidConfig( + featureOverrides: {'build_runner': false}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1533,39 +1906,54 @@ void main() { group('render(existingContent) preserves user sections', () { String _normalizeLf(String input) => input.replaceAll('\r\n', '\n'); - test('user section content is preserved when existingContent has custom lines in a user block', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final base = _normalizeLf(gen.render()); - // Append a user block with content so extraction finds it (first occurrence is empty) - const customBlock = ''' + test( + 'user section content is preserved when existingContent has custom lines in a user block', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final base = _normalizeLf(gen.render()); + // Append a user block with content so extraction finds it (first occurrence is empty) + const customBlock = ''' # --- BEGIN USER: pre-test --- - name: Custom pre-test step run: echo "user-added" # --- END USER: pre-test --- '''; - final existing = base + customBlock; - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('Custom pre-test step')); - expect(rendered, contains('user-added')); - expect(rendered, contains('# --- BEGIN USER: pre-test ---')); - expect(rendered, contains('# --- END USER: pre-test ---')); - }); + final existing = base + customBlock; + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('Custom pre-test step')); + expect(rendered, contains('user-added')); + expect(rendered, contains('# --- BEGIN USER: pre-test ---')); + expect(rendered, contains('# --- END USER: pre-test ---')); + }, + ); - test('CRLF normalization: existing content with \\r\\n still preserves sections', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final base = _normalizeLf(gen.render()); - const customContent = '\r\n - run: echo "crlf-test"\r\n'; - final existing = base.replaceFirst( - '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---$customContent# --- END USER: pre-test ---', - ); - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('crlf-test')); - expect(rendered, contains('# --- BEGIN USER: pre-test ---')); - }); + test( + 'CRLF normalization: existing content with \\r\\n still preserves sections', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final base = _normalizeLf(gen.render()); + const customContent = '\r\n - run: echo "crlf-test"\r\n'; + final existing = base.replaceFirst( + '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', + '# --- BEGIN USER: pre-test ---$customContent# --- END USER: pre-test ---', + ); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('crlf-test')); + expect(rendered, contains('# --- BEGIN USER: pre-test ---')); + }, + ); test('multiple user sections preserve independently', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final base = _normalizeLf(gen.render()); var existing = base; existing = existing.replaceFirst( @@ -1587,31 +1975,46 @@ void main() { expect(rendered, contains('runs-on: ubuntu-latest')); }); - test('empty/whitespace-only existing user section does not overwrite rendered section', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final base = _normalizeLf(gen.render()); - // Existing has pre-test with only whitespace; post-test has real content - final existing = base - .replaceFirst( + test( + 'empty/whitespace-only existing user section does not overwrite rendered section', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final base = _normalizeLf(gen.render()); + // Existing has pre-test with only whitespace; post-test has real content + final existing = base + .replaceFirst( + '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', + '# --- BEGIN USER: pre-test ---\n \n \t \n# --- END USER: pre-test ---', + ) + .replaceFirst( + '# --- BEGIN USER: post-test ---\n# --- END USER: post-test ---', + '# --- BEGIN USER: post-test ---\n - run: echo kept\n# --- END USER: post-test ---', + ); + final rendered = gen.render(existingContent: existing); + // pre-test: whitespace-only was skipped, so rendered keeps empty placeholder + expect( + rendered, + contains( '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---\n \n \t \n# --- END USER: pre-test ---', - ) - .replaceFirst( - '# --- BEGIN USER: post-test ---\n# --- END USER: post-test ---', - '# --- BEGIN USER: post-test ---\n - run: echo kept\n# --- END USER: post-test ---', - ); - final rendered = gen.render(existingContent: existing); - // pre-test: whitespace-only was skipped, so rendered keeps empty placeholder - expect(rendered, contains('# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---')); - // post-test: real content was preserved - expect(rendered, contains('echo kept')); - }); + ), + ); + // post-test: real content was preserved + expect(rendered, contains('echo kept')); + }, + ); test('unknown section name in existing content is silently ignored', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final base = _normalizeLf(gen.render()); // Add a user section that doesn't exist in the skeleton - final existing = '$base\n# --- BEGIN USER: nonexistent ---\n custom: stuff\n# --- END USER: nonexistent ---\n'; + final existing = + '$base\n# --- BEGIN USER: nonexistent ---\n custom: stuff\n# --- END USER: nonexistent ---\n'; final rendered = gen.render(existingContent: existing); // The unknown section content should not appear in the rendered output // (there's no matching placeholder to insert it into) @@ -1621,7 +2024,10 @@ void main() { }); test('malformed section markers (missing END) are ignored', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final base = _normalizeLf(gen.render()); // Inject a BEGIN without matching END — regex won't match, so it's ignored final existing = base.replaceFirst( @@ -1634,7 +2040,10 @@ void main() { }); test('mismatched section names (BEGIN X / END Y) are ignored', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final base = _normalizeLf(gen.render()); final existing = base.replaceFirst( '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', @@ -1645,24 +2054,36 @@ void main() { expect(rendered, isNot(contains('echo mismatch'))); }); - test('section content with regex-special characters is preserved verbatim', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final base = _normalizeLf(gen.render()); - // Content with regex special chars: $, (), *, +, ?, |, ^, {, } - const specialContent = r' - run: echo "${{ matrix.os }}" && test [[ "$(whoami)" == "ci" ]]'; - final existing = base.replaceFirst( - '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---\n$specialContent\n# --- END USER: pre-test ---', - ); - final rendered = gen.render(existingContent: existing); - expect(rendered, contains(specialContent)); - }); + test( + 'section content with regex-special characters is preserved verbatim', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final base = _normalizeLf(gen.render()); + // Content with regex special chars: $, (), *, +, ?, |, ^, {, } + const specialContent = + r' - run: echo "${{ matrix.os }}" && test [[ "$(whoami)" == "ci" ]]'; + final existing = base.replaceFirst( + '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', + '# --- BEGIN USER: pre-test ---\n$specialContent\n# --- END USER: pre-test ---', + ); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains(specialContent)); + }, + ); - test('duplicate user section markers in existing content: last matched section wins', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final base = _normalizeLf(gen.render()); - final existing = - ''' + test( + 'duplicate user section markers in existing content: last matched section wins', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final base = _normalizeLf(gen.render()); + final existing = + ''' $base # --- BEGIN USER: pre-test --- - run: echo first @@ -1671,32 +2092,50 @@ $base - run: echo second # --- END USER: pre-test --- '''; - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('echo second')); - expect(rendered, isNot(contains('echo first'))); - }); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('echo second')); + expect(rendered, isNot(contains('echo first'))); + }, + ); - test('null existingContent produces same output as no existingContent', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final withoutExisting = gen.render(); - final withNull = gen.render(existingContent: null); - expect(withNull, equals(withoutExisting)); - }); + test( + 'null existingContent produces same output as no existingContent', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final withoutExisting = gen.render(); + final withNull = gen.render(existingContent: null); + expect(withNull, equals(withoutExisting)); + }, + ); - test('existingContent with no user sections produces same output as fresh render', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final fresh = _normalizeLf(gen.render()); - // Use a completely unrelated string as existing content - final rendered = _normalizeLf(gen.render(existingContent: 'name: SomeOtherWorkflow\non: push')); - expect(rendered, equals(fresh)); - }); + test( + 'existingContent with no user sections produces same output as fresh render', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final fresh = _normalizeLf(gen.render()); + // Use a completely unrelated string as existing content + final rendered = _normalizeLf( + gen.render(existingContent: 'name: SomeOtherWorkflow\non: push'), + ); + expect(rendered, equals(fresh)); + }, + ); }); // ---- render() feature flag combinations ---- group('feature flag combinations', () { test('format_check + web_test: web-test needs includes auto-format', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'format_check': true}), + ciConfig: _minimalValidConfig( + webTest: true, + featureOverrides: {'format_check': true}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1709,26 +2148,37 @@ $base test('format_check renders repo-wide dart format command (.)', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'format_check': true}), + ciConfig: _minimalValidConfig( + featureOverrides: {'format_check': true}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); expect(rendered, contains('run: dart format --line-length 120 .')); }); - test('git-config steps use env indirection (GH_PAT) instead of inline secrets in run', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); - final rendered = gen.render(); - expect(rendered, contains('GH_PAT: \${{ secrets.')); - expect(rendered, contains('echo "::add-mask::\${GH_PAT}"')); - expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); - }); + test( + 'git-config steps use env indirection (GH_PAT) instead of inline secrets in run', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('GH_PAT: \${{ secrets.')); + expect(rendered, contains('echo "::add-mask::\${GH_PAT}"')); + expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); + }, + ); test('custom git_orgs render configurable org rewrite rules', () { final config = _minimalValidConfig() ..['git_orgs'] = ['acme-runtime'] ..['personal_access_token_secret'] = 'GITHUB_TOKEN'; - final gen = WorkflowGenerator(ciConfig: config, toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: config, + toolingVersion: '0.0.0-test', + ); final rendered = gen.render(); expect( rendered, @@ -1740,32 +2190,47 @@ $base expect(rendered, isNot(contains('git@github.com:pieces-app/'))); }); - test('web_test without format_check: web-test needs omits auto-format', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('pre-check')); - expect(needs, isNot(contains('auto-format'))); - }); + test( + 'web_test without format_check: web-test needs omits auto-format', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('pre-check')); + expect(needs, isNot(contains('auto-format'))); + }, + ); - test('build_runner + web_test: web-test job contains build_runner step', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'build_runner': true}), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - // Find the web-test job section and check it contains build_runner - final webTestStart = rendered.indexOf('web-test:'); - expect(webTestStart, isNot(-1)); - final afterWebTest = rendered.substring(webTestStart); - expect(afterWebTest, contains('Run build_runner')); - }); + test( + 'build_runner + web_test: web-test job contains build_runner step', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + webTest: true, + featureOverrides: {'build_runner': true}, + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + // Find the web-test job section and check it contains build_runner + final webTestStart = rendered.indexOf('web-test:'); + expect(webTestStart, isNot(-1)); + final afterWebTest = rendered.substring(webTestStart); + expect(afterWebTest, contains('Run build_runner')); + }, + ); test('proto + web_test: web-test job contains proto steps', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'proto': true}), + ciConfig: _minimalValidConfig( + webTest: true, + featureOverrides: {'proto': true}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1776,51 +2241,70 @@ $base expect(afterWebTest, contains('Verify proto files')); }); - test('multi-platform + web_test: web-test depends on analyze (not test)', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu', 'macos']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('analyze')); - expect(needs, isNot(contains('test'))); - expect(needs, isNot(contains('analyze-and-test'))); - }); + test( + 'multi-platform + web_test: web-test depends on analyze (not test)', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + webTest: true, + platforms: ['ubuntu', 'macos'], + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('analyze')); + expect(needs, isNot(contains('test'))); + expect(needs, isNot(contains('analyze-and-test'))); + }, + ); - test('single-platform + web_test: web-test depends on analyze-and-test', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('pre-check')); - expect(needs, contains('analyze-and-test')); - }); + test( + 'single-platform + web_test: web-test depends on analyze-and-test', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('pre-check')); + expect(needs, contains('analyze-and-test')); + }, + ); - test('single-platform uses explicit PLATFORM_ID from single_platform_id context', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['windows-x64']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final job = parsed['jobs']['analyze-and-test'] as YamlMap; - final steps = (job['steps'] as YamlList).toList(); - final testStep = steps.firstWhere((s) => s is YamlMap && s['name'] == 'Test', orElse: () => null); - expect(testStep, isNotNull); - final env = (testStep as YamlMap)['env'] as YamlMap; - expect(env['PLATFORM_ID'], equals('windows-x64')); - }); + test( + 'single-platform uses explicit PLATFORM_ID from single_platform_id context', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + platforms: ['windows-x64'], + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final job = parsed['jobs']['analyze-and-test'] as YamlMap; + final steps = (job['steps'] as YamlList).toList(); + final testStep = steps.firstWhere( + (s) => s is YamlMap && s['name'] == 'Test', + orElse: () => null, + ); + expect(testStep, isNotNull); + final env = (testStep as YamlMap)['env'] as YamlMap; + expect(env['PLATFORM_ID'], equals('windows-x64')); + }, + ); test('secrets render in web-test job env block', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true)..['secrets'] = {'API_KEY': 'MY_SECRET'}, + ciConfig: _minimalValidConfig(webTest: true) + ..['secrets'] = {'API_KEY': 'MY_SECRET'}, toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1833,7 +2317,10 @@ $base test('lfs + web_test: web-test checkout has lfs: true', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'lfs': true}), + ciConfig: _minimalValidConfig( + webTest: true, + featureOverrides: {'lfs': true}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1843,19 +2330,28 @@ $base expect(afterWebTest, contains('lfs: true')); }); - test('managed_test in multi-platform: test job uses managed test command', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu', 'macos']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final testJob = parsed['jobs']['test'] as YamlMap; - final steps = (testJob['steps'] as YamlList).toList(); - final testStep = steps.firstWhere((s) => s is YamlMap && s['name'] == 'Test', orElse: () => null); - expect(testStep, isNotNull); - expect((testStep as YamlMap)['run'], contains('manage_cicd test')); - }); + test( + 'managed_test in multi-platform: test job uses managed test command', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + platforms: ['ubuntu', 'macos'], + ), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final testJob = parsed['jobs']['test'] as YamlMap; + final steps = (testJob['steps'] as YamlList).toList(); + final testStep = steps.firstWhere( + (s) => s is YamlMap && s['name'] == 'Test', + orElse: () => null, + ); + expect(testStep, isNotNull); + expect((testStep as YamlMap)['run'], contains('manage_cicd test')); + }, + ); test('all features enabled renders valid YAML', () { final gen = WorkflowGenerator( @@ -1897,7 +2393,10 @@ $base }); test('no features enabled (all false) renders minimal valid YAML', () { - final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final rendered = gen.render(); final parsed = loadYaml(rendered) as YamlMap; final jobs = parsed['jobs'] as YamlMap; @@ -1930,7 +2429,8 @@ $base test('runner_overrides change runs-on in single-platform', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig()..['runner_overrides'] = {'ubuntu': 'my-custom-runner'}, + ciConfig: _minimalValidConfig() + ..['runner_overrides'] = {'ubuntu': 'my-custom-runner'}, toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1941,7 +2441,9 @@ $base test('artifact retention-days policy applied consistently (7 days)', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}), + ciConfig: _minimalValidConfig( + featureOverrides: {'managed_test': true}, + ), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1949,27 +2451,89 @@ $base expect(rendered, contains('Policy: test artifact retention-days = 7')); }); - test('artifact retention-days can be overridden via ci.artifact_retention_days', () { - final ci = _minimalValidConfig(featureOverrides: {'managed_test': true}); - ci['artifact_retention_days'] = 14; - final gen = WorkflowGenerator(ciConfig: ci, toolingVersion: '0.0.0-test'); + test( + 'artifact retention-days can be overridden via ci.artifact_retention_days', + () { + final ci = _minimalValidConfig( + featureOverrides: {'managed_test': true}, + ); + ci['artifact_retention_days'] = 14; + final gen = WorkflowGenerator( + ciConfig: ci, + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('retention-days: 14')); + expect( + rendered, + contains('Policy: test artifact retention-days = 14'), + ); + }, + ); + + test( + 'Windows pub-cache path uses format for Dart default (%LOCALAPPDATA%\\Pub\\Cache)', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(platforms: ['windows']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('Pub')); + expect(rendered, contains('Cache')); + expect(rendered, contains('env.LOCALAPPDATA')); + expect(rendered, contains("'~/.pub-cache'")); + expect(rendered, contains("runner.os == 'Windows'")); + }, + ); + + // Issue #12: shared step partials — verify dedup behavior + test('shared partials are resolved (no raw partial tags in output)', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(), + toolingVersion: '0.0.0-test', + ); final rendered = gen.render(); - expect(rendered, contains('retention-days: 14')); - expect(rendered, contains('Policy: test artifact retention-days = 14')); + expect(rendered, isNot(contains('<%> shared_'))); + expect(rendered, isNot(contains('shared_setup_through_build'))); + expect(rendered, isNot(contains('shared_analysis_block'))); }); - test('Windows pub-cache path uses format for Dart default (%LOCALAPPDATA%\\Pub\\Cache)', () { + test('shared step markers from partials appear in rendered output', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(platforms: ['windows']), + ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); - expect(rendered, contains('Pub')); - expect(rendered, contains('Cache')); - expect(rendered, contains('env.LOCALAPPDATA')); - expect(rendered, contains("'~/.pub-cache'")); - expect(rendered, contains("runner.os == 'Windows'")); - }); + expect(rendered, contains('# ── shared:checkout ──')); + expect(rendered, contains('# ── shared:git-config ──')); + expect(rendered, contains('# ── shared:dart-setup ──')); + expect(rendered, contains('# ── shared:pub-cache ──')); + }); + + test( + 'multi-platform jobs use same shared setup partial (analyze and test)', + () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final analyzeIdx = rendered.indexOf(' analyze:'); + final testIdx = rendered.indexOf(' test:'); + expect(analyzeIdx, isNot(-1)); + expect(testIdx, isNot(-1)); + final analyzeSection = rendered.substring(analyzeIdx, testIdx); + final testSection = rendered.substring(testIdx); + expect( + analyzeSection, + contains('Configure Git for HTTPS with Token'), + ); + expect(testSection, contains('Configure Git for HTTPS with Token')); + expect(analyzeSection, contains('Cache Dart pub dependencies')); + expect(testSection, contains('Cache Dart pub dependencies')); + }, + ); }); }); } From e6f35ae87b1c5767215aac044c8d7d58d368d8f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:52:26 +0000 Subject: [PATCH 3/3] bot(format): apply dart format --line-length 120 [skip ci] --- test/cli_utils_test.dart | 464 +++----- test/update_command_test.dart | 143 +-- test/workflow_generator_test.dart | 1626 ++++++++++------------------- 3 files changed, 760 insertions(+), 1473 deletions(-) diff --git a/test/cli_utils_test.dart b/test/cli_utils_test.dart index 00cbe38..cb5a475 100644 --- a/test/cli_utils_test.dart +++ b/test/cli_utils_test.dart @@ -49,10 +49,7 @@ void main() { }); test('returns default path when TEST_LOG_DIR is unset', () { - final resolved = RepoUtils.resolveTestLogDir( - repoRoot, - environment: const {}, - ); + final resolved = RepoUtils.resolveTestLogDir(repoRoot, environment: const {}); expect(resolved, equals(p.join(repoRoot, '.dart_tool', 'test-logs'))); }); @@ -76,10 +73,8 @@ void main() { test('throws when TEST_LOG_DIR is relative', () { expect( - () => RepoUtils.resolveTestLogDir( - repoRoot, - environment: const {'TEST_LOG_DIR': 'relative/path'}, - ), + () => + RepoUtils.resolveTestLogDir(repoRoot, environment: const {'TEST_LOG_DIR': 'relative/path'}), throwsA(isA()), ); }); @@ -88,10 +83,7 @@ void main() { final runnerTemp = p.join(repoRoot, 'runner-temp'); final outside = p.join(repoRoot, 'outside', 'logs'); expect( - () => RepoUtils.resolveTestLogDir( - repoRoot, - environment: {'RUNNER_TEMP': runnerTemp, 'TEST_LOG_DIR': outside}, - ), + () => RepoUtils.resolveTestLogDir(repoRoot, environment: {'RUNNER_TEMP': runnerTemp, 'TEST_LOG_DIR': outside}), throwsA(isA()), ); }); @@ -111,10 +103,7 @@ void main() { expect( () => RepoUtils.resolveTestLogDir( repoRoot, - environment: { - 'RUNNER_TEMP': '/tmp/runner\nbad', - 'TEST_LOG_DIR': inside, - }, + environment: {'RUNNER_TEMP': '/tmp/runner\nbad', 'TEST_LOG_DIR': inside}, ), throwsA(isA()), ); @@ -154,35 +143,19 @@ void main() { expect(File(filePath).readAsStringSync(), equals('hello world')); }); - test( - 'ensureSafeDirectory rejects symlink-backed directories', - skip: !symlinksSupported, - () { - final targetDir = Directory(p.join(tempDir.path, 'target')) - ..createSync(recursive: true); - final linkDirPath = p.join(tempDir.path, 'linked'); - Link(linkDirPath).createSync(targetDir.path); - expect( - () => RepoUtils.ensureSafeDirectory(linkDirPath), - throwsA(isA()), - ); - }, - ); - - test( - 'writeFileSafely rejects symlink file targets', - skip: !symlinksSupported, - () { - final targetFile = File(p.join(tempDir.path, 'target.txt')) - ..writeAsStringSync('base'); - final linkPath = p.join(tempDir.path, 'linked.txt'); - Link(linkPath).createSync(targetFile.path); - expect( - () => RepoUtils.writeFileSafely(linkPath, 'new content'), - throwsA(isA()), - ); - }, - ); + test('ensureSafeDirectory rejects symlink-backed directories', skip: !symlinksSupported, () { + final targetDir = Directory(p.join(tempDir.path, 'target'))..createSync(recursive: true); + final linkDirPath = p.join(tempDir.path, 'linked'); + Link(linkDirPath).createSync(targetDir.path); + expect(() => RepoUtils.ensureSafeDirectory(linkDirPath), throwsA(isA())); + }); + + test('writeFileSafely rejects symlink file targets', skip: !symlinksSupported, () { + final targetFile = File(p.join(tempDir.path, 'target.txt'))..writeAsStringSync('base'); + final linkPath = p.join(tempDir.path, 'linked.txt'); + Link(linkPath).createSync(targetFile.path); + expect(() => RepoUtils.writeFileSafely(linkPath, 'new content'), throwsA(isA())); + }); }); group('TestResultsUtil.parseTestResultsJson', () { @@ -219,35 +192,27 @@ void main() { expect(results.failures, isEmpty); }); - test( - 'returns unparsed results when NDJSON file has only blank lines', - () async { - final jsonPath = p.join(tempDir.path, 'blank.json'); - File(jsonPath).writeAsStringSync('\n \n\t\n'); - final results = await TestResultsUtil.parseTestResultsJson(jsonPath); - expect(results.parsed, isFalse); - expect(results.passed, equals(0)); - expect(results.failed, equals(0)); - expect(results.skipped, equals(0)); - expect(results.failures, isEmpty); - }, - ); - - test( - 'returns unparsed results when file has valid JSON but no structured events', - () async { - final jsonPath = p.join(tempDir.path, 'no_events.json'); - File( - jsonPath, - ).writeAsStringSync('{"type":"unknown","data":1}\n{"other":"value"}\n'); - final results = await TestResultsUtil.parseTestResultsJson(jsonPath); - expect(results.parsed, isFalse); - expect(results.passed, equals(0)); - expect(results.failed, equals(0)); - expect(results.skipped, equals(0)); - expect(results.failures, isEmpty); - }, - ); + test('returns unparsed results when NDJSON file has only blank lines', () async { + final jsonPath = p.join(tempDir.path, 'blank.json'); + File(jsonPath).writeAsStringSync('\n \n\t\n'); + final results = await TestResultsUtil.parseTestResultsJson(jsonPath); + expect(results.parsed, isFalse); + expect(results.passed, equals(0)); + expect(results.failed, equals(0)); + expect(results.skipped, equals(0)); + expect(results.failures, isEmpty); + }); + + test('returns unparsed results when file has valid JSON but no structured events', () async { + final jsonPath = p.join(tempDir.path, 'no_events.json'); + File(jsonPath).writeAsStringSync('{"type":"unknown","data":1}\n{"other":"value"}\n'); + final results = await TestResultsUtil.parseTestResultsJson(jsonPath); + expect(results.parsed, isFalse); + expect(results.passed, equals(0)); + expect(results.failed, equals(0)); + expect(results.skipped, equals(0)); + expect(results.failures, isEmpty); + }); test('parses pass/fail/skipped counts and failure details', () async { final jsonPath = p.join(tempDir.path, 'results.json'); @@ -377,12 +342,7 @@ void main() { }); group('TestResultsUtil.writeTestJobSummary', () { - TestResults _parsed({ - required int passed, - required int failed, - required int skipped, - int durationMs = 500, - }) { + TestResults _parsed({required int passed, required int failed, required int skipped, int durationMs = 500}) { final results = TestResults() ..parsed = true ..passed = passed @@ -392,50 +352,39 @@ void main() { return results; } - test( - 'emits NOTE when parsed results are successful and exit code is 0', - () { - String? summary; - final results = _parsed(passed: 3, failed: 0, skipped: 1); - - TestResultsUtil.writeTestJobSummary( - results, - 0, - platformId: 'linux-x64', - writeSummary: (markdown) => summary = markdown, - ); + test('emits NOTE when parsed results are successful and exit code is 0', () { + String? summary; + final results = _parsed(passed: 3, failed: 0, skipped: 1); - expect(summary, isNotNull); - expect(summary!, contains('## Test Results — linux-x64')); - expect(summary!, contains('> [!NOTE]')); - expect(summary!, contains('All 4 tests passed')); - }, - ); - - test( - 'emits CAUTION when exit code is non-zero even if failed count is zero', - () { - String? summary; - final results = _parsed(passed: 2, failed: 0, skipped: 0); - - TestResultsUtil.writeTestJobSummary( - results, - 1, - platformId: 'linux ', - writeSummary: (markdown) => summary = markdown, - ); + TestResultsUtil.writeTestJobSummary( + results, + 0, + platformId: 'linux-x64', + writeSummary: (markdown) => summary = markdown, + ); - expect(summary, isNotNull); - expect(summary!, contains('## Test Results — linux <x64>')); - expect(summary!, contains('> [!CAUTION]')); - expect( - summary!, - contains( - 'Tests exited with code 1 despite no structured test failures.', - ), - ); - }, - ); + expect(summary, isNotNull); + expect(summary!, contains('## Test Results — linux-x64')); + expect(summary!, contains('> [!NOTE]')); + expect(summary!, contains('All 4 tests passed')); + }); + + test('emits CAUTION when exit code is non-zero even if failed count is zero', () { + String? summary; + final results = _parsed(passed: 2, failed: 0, skipped: 0); + + TestResultsUtil.writeTestJobSummary( + results, + 1, + platformId: 'linux ', + writeSummary: (markdown) => summary = markdown, + ); + + expect(summary, isNotNull); + expect(summary!, contains('## Test Results — linux <x64>')); + expect(summary!, contains('> [!CAUTION]')); + expect(summary!, contains('Tests exited with code 1 despite no structured test failures.')); + }); test('emits CAUTION for unparsed results with non-zero exit code', () { String? summary; @@ -450,12 +399,7 @@ void main() { expect(summary, isNotNull); expect(summary!, contains('> [!CAUTION]')); - expect( - summary!, - contains( - 'Tests failed (exit code 7) — no structured results available.', - ), - ); + expect(summary!, contains('Tests failed (exit code 7) — no structured results available.')); }); test('emits NOTE for unparsed results with zero exit code', () { @@ -471,25 +415,14 @@ void main() { expect(summary, isNotNull); expect(summary!, contains('> [!NOTE]')); - expect( - summary!, - contains( - 'Tests passed (exit code 0) — no structured results available.', - ), - ); + expect(summary!, contains('Tests passed (exit code 0) — no structured results available.')); }); test('emits CAUTION when parsed results contain failures', () { String? summary; final results = _parsed(passed: 1, failed: 1, skipped: 0); results.failures.add( - TestFailure( - name: 'failing test', - error: 'boom', - stackTrace: 'trace', - printOutput: '', - durationMs: 12, - ), + TestFailure(name: 'failing test', error: 'boom', stackTrace: 'trace', printOutput: '', durationMs: 12), ); TestResultsUtil.writeTestJobSummary( @@ -529,12 +462,7 @@ void main() { ); expect(summary, isNotNull); - expect( - summary!, - contains( - '_...and 5 more failures. See test logs artifact for full details._', - ), - ); + expect(summary!, contains('_...and 5 more failures. See test logs artifact for full details._')); expect(summary!, isNot(contains('failing test 24'))); }); @@ -594,10 +522,7 @@ void main() { group('Utf8BoundedBuffer', () { test('appends full content when under byte limit', () { - final buffer = Utf8BoundedBuffer( - maxBytes: 20, - truncationSuffix: '...[truncated]', - ); + final buffer = Utf8BoundedBuffer(maxBytes: 20, truncationSuffix: '...[truncated]'); buffer.append('hello'); buffer.append(' world'); expect(buffer.isTruncated, isFalse); @@ -614,18 +539,12 @@ void main() { expect(buffer.byteLength, equals(9)); }); - test( - 'never exceeds maxBytes even when suffix is longer than remaining budget', - () { - final buffer = Utf8BoundedBuffer( - maxBytes: 4, - truncationSuffix: '...[truncated]', - ); - buffer.append('abcdefgh'); - expect(buffer.isTruncated, isTrue); - expect(utf8.encode(buffer.toString()).length, lessThanOrEqualTo(4)); - }, - ); + test('never exceeds maxBytes even when suffix is longer than remaining budget', () { + final buffer = Utf8BoundedBuffer(maxBytes: 4, truncationSuffix: '...[truncated]'); + buffer.append('abcdefgh'); + expect(buffer.isTruncated, isTrue); + expect(utf8.encode(buffer.toString()).length, lessThanOrEqualTo(4)); + }); }); group('StepSummary', () { @@ -642,10 +561,7 @@ void main() { File(summaryPath).writeAsStringSync('x' * (maxBytes - 2)); expect(File(summaryPath).lengthSync(), equals(maxBytes - 2)); - StepSummary.write( - '語', - environment: {'GITHUB_STEP_SUMMARY': summaryPath}, - ); + StepSummary.write('語', environment: {'GITHUB_STEP_SUMMARY': summaryPath}); // Should skip append (would exceed); file size unchanged expect(File(summaryPath).lengthSync(), equals(maxBytes - 2)); } finally { @@ -681,9 +597,7 @@ void main() { void _writeConfig(Map ci) { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File( - '${configDir.path}/config.json', - ).writeAsStringSync(json.encode({'ci': ci})); + File('${configDir.path}/config.json').writeAsStringSync(json.encode({'ci': ci})); } test('returns empty when no sub_packages', () { @@ -778,59 +692,45 @@ void main() { {'name': 'api', 'path': 'packages/api'}, ]; - test( - 'buildHierarchicalDocumentationInstructions includes package structure', - () { - final instructions = - SubPackageUtils.buildHierarchicalDocumentationInstructions( - newVersion: '1.2.3', - subPackages: subPackages, - ); - - expect(instructions, contains('Hierarchical Documentation Format')); - expect(instructions, contains('core')); - expect(instructions, contains('api')); - expect(instructions, contains('top-level overview')); - }, - ); - - test( - 'buildHierarchicalAutodocInstructions includes module and package context', - () { - final instructions = - SubPackageUtils.buildHierarchicalAutodocInstructions( - moduleName: 'Analyzer Engine', - subPackages: subPackages, - moduleSubPackage: 'core', - ); - - expect(instructions, contains('Multi-Package Autodoc Context')); - expect(instructions, contains('Analyzer Engine')); - expect(instructions, contains('"core"')); - expect(instructions, contains('core, api')); - }, - ); - - test( - 'hierarchical instruction builders return empty for single-package repos', - () { - expect( - SubPackageUtils.buildHierarchicalDocumentationInstructions( - newVersion: '1.2.3', - subPackages: const [], - ), - isEmpty, - ); - expect( - SubPackageUtils.buildHierarchicalAutodocInstructions( - moduleName: 'Any', - subPackages: const [], - moduleSubPackage: null, - ), - isEmpty, - ); - }, - ); + test('buildHierarchicalDocumentationInstructions includes package structure', () { + final instructions = SubPackageUtils.buildHierarchicalDocumentationInstructions( + newVersion: '1.2.3', + subPackages: subPackages, + ); + + expect(instructions, contains('Hierarchical Documentation Format')); + expect(instructions, contains('core')); + expect(instructions, contains('api')); + expect(instructions, contains('top-level overview')); + }); + + test('buildHierarchicalAutodocInstructions includes module and package context', () { + final instructions = SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Analyzer Engine', + subPackages: subPackages, + moduleSubPackage: 'core', + ); + + expect(instructions, contains('Multi-Package Autodoc Context')); + expect(instructions, contains('Analyzer Engine')); + expect(instructions, contains('"core"')); + expect(instructions, contains('core, api')); + }); + + test('hierarchical instruction builders return empty for single-package repos', () { + expect( + SubPackageUtils.buildHierarchicalDocumentationInstructions(newVersion: '1.2.3', subPackages: const []), + isEmpty, + ); + expect( + SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Any', + subPackages: const [], + moduleSubPackage: null, + ), + isEmpty, + ); + }); }); group('autodoc index path', () { @@ -848,65 +748,35 @@ void main() { group('resolveAutodocOutputPath', () { test('returns configured path unchanged when no moduleSubPackage', () { - expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs/foo', - moduleSubPackage: null, - ), - equals('docs/foo'), - ); - expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs', - moduleSubPackage: null, - ), - equals('docs'), - ); + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs/foo', moduleSubPackage: null), equals('docs/foo')); + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs', moduleSubPackage: null), equals('docs')); }); test('treats docs/ as already scoped (no duplication)', () { expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs/my_pkg', - moduleSubPackage: 'my_pkg', - ), + resolveAutodocOutputPath(configuredOutputPath: 'docs/my_pkg', moduleSubPackage: 'my_pkg'), equals('docs/my_pkg'), ); }); test('treats docs//nested as already scoped', () { expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs/my_pkg/api', - moduleSubPackage: 'my_pkg', - ), + resolveAutodocOutputPath(configuredOutputPath: 'docs/my_pkg/api', moduleSubPackage: 'my_pkg'), equals('docs/my_pkg/api'), ); }); test('scopes unscoped path when moduleSubPackage present', () { + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs', moduleSubPackage: 'my_pkg'), equals('docs/my_pkg')); expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs', - moduleSubPackage: 'my_pkg', - ), - equals('docs/my_pkg'), - ); - expect( - resolveAutodocOutputPath( - configuredOutputPath: 'docs/other', - moduleSubPackage: 'my_pkg', - ), + resolveAutodocOutputPath(configuredOutputPath: 'docs/other', moduleSubPackage: 'my_pkg'), equals('docs/my_pkg/other'), ); }); test('preserves sub-package scoped docs paths outside root docs/', () { expect( - resolveAutodocOutputPath( - configuredOutputPath: 'packages/core/docs/utils/', - moduleSubPackage: 'core', - ), + resolveAutodocOutputPath(configuredOutputPath: 'packages/core/docs/utils/', moduleSubPackage: 'core'), equals('packages/core/docs/utils'), ); }); @@ -914,69 +784,43 @@ void main() { test('normalization is idempotent (no drift across runs)', () { const path = 'docs/my_pkg'; const subPkg = 'my_pkg'; - final first = resolveAutodocOutputPath( - configuredOutputPath: path, - moduleSubPackage: subPkg, - ); - final second = resolveAutodocOutputPath( - configuredOutputPath: first, - moduleSubPackage: subPkg, - ); + final first = resolveAutodocOutputPath(configuredOutputPath: path, moduleSubPackage: subPkg); + final second = resolveAutodocOutputPath(configuredOutputPath: first, moduleSubPackage: subPkg); expect(first, equals(second)); expect(first, equals('docs/my_pkg')); }); }); group('CiProcessRunner.exec', () { - test( - 'fatal path exits with process exit code after flushing stdout/stderr', - () async { - final scriptPath = p.join( - p.current, - 'test', - 'scripts', - 'fatal_exit_probe.dart', - ); - final result = Process.runSync(Platform.resolvedExecutable, [ - 'run', - scriptPath, - ], runInShell: false); - final expectedCode = Platform.isWindows ? 7 : 1; - expect( - result.exitCode, - equals(expectedCode), - reason: 'fatal exec should exit with failing command exit code', - ); - }, - ); + test('fatal path exits with process exit code after flushing stdout/stderr', () async { + final scriptPath = p.join(p.current, 'test', 'scripts', 'fatal_exit_probe.dart'); + final result = Process.runSync(Platform.resolvedExecutable, ['run', scriptPath], runInShell: false); + final expectedCode = Platform.isWindows ? 7 : 1; + expect(result.exitCode, equals(expectedCode), reason: 'fatal exec should exit with failing command exit code'); + }); }); group('CiProcessRunner.runWithTimeout', () { test('completes normally when process finishes within timeout', () async { - final result = await CiProcessRunner.runWithTimeout( - Platform.resolvedExecutable, - ['--version'], - timeout: const Duration(seconds: 10), - ); + final result = await CiProcessRunner.runWithTimeout(Platform.resolvedExecutable, [ + '--version', + ], timeout: const Duration(seconds: 10)); expect(result.exitCode, equals(0)); expect(result.stdout, contains('Dart')); }); - test( - 'returns timeout result and kills process when timeout exceeded', - () async { - final executable = Platform.isWindows ? 'ping' : 'sleep'; - final args = Platform.isWindows ? ['127.0.0.1', '-n', '60'] : ['60']; - final result = await CiProcessRunner.runWithTimeout( - executable, - args, - timeout: const Duration(milliseconds: 500), - timeoutExitCode: 124, - timeoutMessage: 'Timed out', - ); - expect(result.exitCode, equals(124)); - expect(result.stderr, equals('Timed out')); - }, - ); + test('returns timeout result and kills process when timeout exceeded', () async { + final executable = Platform.isWindows ? 'ping' : 'sleep'; + final args = Platform.isWindows ? ['127.0.0.1', '-n', '60'] : ['60']; + final result = await CiProcessRunner.runWithTimeout( + executable, + args, + timeout: const Duration(milliseconds: 500), + timeoutExitCode: 124, + timeoutMessage: 'Timed out', + ); + expect(result.exitCode, equals(124)); + expect(result.stderr, equals('Timed out')); + }); }); } diff --git a/test/update_command_test.dart b/test/update_command_test.dart index 9cba07a..79e3c42 100644 --- a/test/update_command_test.dart +++ b/test/update_command_test.dart @@ -22,15 +22,13 @@ void main() { if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); }); - test( - 'emits unified diff when skipping due to local customizations and --diff is set', - () async { - // Consumer repo: pubspec, config, overwritable file with local edits, - // template_versions with consumer_hash != current file (local changes). - final repoRoot = tempDir.path; + test('emits unified diff when skipping due to local customizations and --diff is set', () async { + // Consumer repo: pubspec, config, overwritable file with local edits, + // template_versions with consumer_hash != current file (local changes). + final repoRoot = tempDir.path; - // pubspec with path dep to runtime_ci_tooling (different name to avoid self-dep) - File(p.join(repoRoot, 'pubspec.yaml')).writeAsStringSync(''' + // pubspec with path dep to runtime_ci_tooling (different name to avoid self-dep) + File(p.join(repoRoot, 'pubspec.yaml')).writeAsStringSync(''' name: update_test_consumer version: 0.0.0 environment: @@ -40,91 +38,64 @@ dependencies: path: $packagePath '''); - // .runtime_ci/config.json (repository.name must match pubspec for RepoUtils) - Directory(p.join(repoRoot, '.runtime_ci')).createSync(recursive: true); - File(p.join(repoRoot, '.runtime_ci', 'config.json')).writeAsStringSync( - json.encode({ - 'repository': {'name': 'update_test_consumer', 'owner': 'test'}, - }), - ); + // .runtime_ci/config.json (repository.name must match pubspec for RepoUtils) + Directory(p.join(repoRoot, '.runtime_ci')).createSync(recursive: true); + File(p.join(repoRoot, '.runtime_ci', 'config.json')).writeAsStringSync( + json.encode({ + 'repository': {'name': 'update_test_consumer', 'owner': 'test'}, + }), + ); - // Consumer file with local modifications (differs from template) - const localContent = - '{"_comment":"local customization","model":{"maxSessionTurns":99}}\n'; - Directory(p.join(repoRoot, '.gemini')).createSync(recursive: true); - File( - p.join(repoRoot, '.gemini', 'settings.json'), - ).writeAsStringSync(localContent); + // Consumer file with local modifications (differs from template) + const localContent = '{"_comment":"local customization","model":{"maxSessionTurns":99}}\n'; + Directory(p.join(repoRoot, '.gemini')).createSync(recursive: true); + File(p.join(repoRoot, '.gemini', 'settings.json')).writeAsStringSync(localContent); - // Template versions: consumer_hash = hash of "original" content so we - // detect local changes. hash = old template hash so template appears - // changed (we enter update path, not "up to date"). - final originalContent = '{"_comment":"original"}\n'; - final originalHash = sha256 - .convert(originalContent.codeUnits) - .toString(); - const oldTemplateHash = - '0000000000000000000000000000000000000000000000000000000000000001'; + // Template versions: consumer_hash = hash of "original" content so we + // detect local changes. hash = old template hash so template appears + // changed (we enter update path, not "up to date"). + final originalContent = '{"_comment":"original"}\n'; + final originalHash = sha256.convert(originalContent.codeUnits).toString(); + const oldTemplateHash = '0000000000000000000000000000000000000000000000000000000000000001'; - File( - p.join(repoRoot, '.runtime_ci', 'template_versions.json'), - ).writeAsStringSync( - json.encode({ - 'tooling_version': '0.0.0', - 'updated_at': DateTime.now().toUtc().toIso8601String(), - 'templates': { - 'gemini_settings': { - 'hash': oldTemplateHash, - 'consumer_hash': originalHash, - 'updated_at': DateTime.now().toUtc().toIso8601String(), - }, + File(p.join(repoRoot, '.runtime_ci', 'template_versions.json')).writeAsStringSync( + json.encode({ + 'tooling_version': '0.0.0', + 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'templates': { + 'gemini_settings': { + 'hash': oldTemplateHash, + 'consumer_hash': originalHash, + 'updated_at': DateTime.now().toUtc().toIso8601String(), }, - }), - ); + }, + }), + ); - // Resolve dependencies so package_config.json exists - final pubGet = await Process.run('dart', [ - 'pub', - 'get', - ], workingDirectory: repoRoot); - expect(pubGet.exitCode, equals(0), reason: 'pub get must succeed'); + // Resolve dependencies so package_config.json exists + final pubGet = await Process.run('dart', ['pub', 'get'], workingDirectory: repoRoot); + expect(pubGet.exitCode, equals(0), reason: 'pub get must succeed'); - // Run manage_cicd update --diff --templates from temp repo - final result = await Process.run( - 'dart', - [ - 'run', - 'runtime_ci_tooling:manage_cicd', - 'update', - '--diff', - '--templates', - ], - workingDirectory: repoRoot, - runInShell: false, - environment: {'PATH': Platform.environment['PATH'] ?? ''}, - ); + // Run manage_cicd update --diff --templates from temp repo + final result = await Process.run( + 'dart', + ['run', 'runtime_ci_tooling:manage_cicd', 'update', '--diff', '--templates'], + workingDirectory: repoRoot, + runInShell: false, + environment: {'PATH': Platform.environment['PATH'] ?? ''}, + ); - final stdout = result.stdout as String; - final stderr = result.stderr as String; - final combined = '$stdout\n$stderr'; + final stdout = result.stdout as String; + final stderr = result.stderr as String; + final combined = '$stdout\n$stderr'; - expect( - combined, - contains('local customizations detected'), - reason: 'Should warn about local customizations', - ); - expect( - combined, - contains('[diff]'), - reason: - 'PR #36: --diff must emit diff preview on local-customization skip path', - ); - expect( - combined, - contains('.gemini/settings.json'), - reason: 'Diff should reference the overwritable file path', - ); - }, - ); + expect(combined, contains('local customizations detected'), reason: 'Should warn about local customizations'); + expect( + combined, + contains('[diff]'), + reason: 'PR #36: --diff must emit diff preview on local-customization skip path', + ); + expect(combined, contains('.gemini/settings.json'), reason: 'Diff should reference the overwritable file path'); + }); }); } diff --git a/test/workflow_generator_test.dart b/test/workflow_generator_test.dart index 7a5d76c..7137056 100644 --- a/test/workflow_generator_test.dart +++ b/test/workflow_generator_test.dart @@ -39,10 +39,7 @@ String _readToolingVersionFromPubspec() { throw StateError('pubspec.yaml not found in current working directory'); } final content = pubspec.readAsStringSync(); - final match = RegExp( - r'^version:\s*([^\s]+)\s*$', - multiLine: true, - ).firstMatch(content); + final match = RegExp(r'^version:\s*([^\s]+)\s*$', multiLine: true).firstMatch(content); if (match == null) { throw StateError('Could not parse version from pubspec.yaml'); } @@ -57,99 +54,65 @@ void main() { // ---- dart_sdk ---- group('dart_sdk', () { test('missing dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({ - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'features': {}}); expect(errors, contains('ci.dart_sdk is required')); }); test('null dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': null, - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': null, 'features': {}}); expect(errors, contains('ci.dart_sdk is required')); }); test('non-string dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': 42, - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': 42, 'features': {}}); expect(errors, anyElement(contains('must be a string'))); }); test('empty-string dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': '', - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': '', 'features': {}}); expect(errors, anyElement(contains('non-empty'))); }); test('whitespace-only dart_sdk produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': ' ', - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': ' ', 'features': {}}); // After trim the string is empty expect(errors, anyElement(contains('non-empty'))); }); test('dart_sdk with leading/trailing whitespace produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': ' 3.9.2 ', - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': ' 3.9.2 ', 'features': {}}); expect(errors, anyElement(contains('whitespace'))); }); test('dart_sdk with trailing newline triggers whitespace error', () { // A trailing \n makes trimmed != sdk, so the whitespace check fires first. - final errors = WorkflowGenerator.validate({ - 'dart_sdk': '3.9.2\n', - 'features': {}, - }); + final errors = WorkflowGenerator.validate({'dart_sdk': '3.9.2\n', 'features': {}}); expect(errors, anyElement(contains('whitespace'))); }); - test( - 'dart_sdk with embedded tab (after trim is identity) triggers newlines/tabs error', - () { - // A tab in the middle: trim() has no effect but the regex catches it. - final errors = WorkflowGenerator.validate({ - 'dart_sdk': '3.9\t.2', - 'features': {}, - }); - expect(errors, anyElement(contains('newlines/tabs'))); - }, - ); + test('dart_sdk with embedded tab (after trim is identity) triggers newlines/tabs error', () { + // A tab in the middle: trim() has no effect but the regex catches it. + final errors = WorkflowGenerator.validate({'dart_sdk': '3.9\t.2', 'features': {}}); + expect(errors, anyElement(contains('newlines/tabs'))); + }); test('valid semver dart_sdk passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(dartSdk: '3.9.2'), - ); + final errors = WorkflowGenerator.validate(_validConfig(dartSdk: '3.9.2')); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('valid semver with pre-release passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(dartSdk: '3.10.0-beta.1'), - ); + final errors = WorkflowGenerator.validate(_validConfig(dartSdk: '3.10.0-beta.1')); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('channel "stable" passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(dartSdk: 'stable'), - ); + final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'stable')); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); test('channel "beta" passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(dartSdk: 'beta'), - ); + final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'beta')); expect(errors.where((e) => e.contains('dart_sdk')), isEmpty); }); @@ -159,9 +122,7 @@ void main() { }); test('invalid dart_sdk like "latest" produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(dartSdk: 'latest'), - ); + final errors = WorkflowGenerator.validate(_validConfig(dartSdk: 'latest')); expect(errors, anyElement(contains('channel'))); }); @@ -179,10 +140,7 @@ void main() { }); test('non-map features produces error', () { - final errors = WorkflowGenerator.validate({ - 'dart_sdk': '3.9.2', - 'features': 'not_a_map', - }); + final errors = WorkflowGenerator.validate({'dart_sdk': '3.9.2', 'features': 'not_a_map'}); expect(errors, anyElement(contains('features must be an object'))); }); @@ -229,16 +187,12 @@ void main() { // ---- platforms ---- group('platforms', () { test('non-list platforms produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(platforms: null)..['platforms'] = 'ubuntu', - ); + final errors = WorkflowGenerator.validate(_validConfig(platforms: null)..['platforms'] = 'ubuntu'); expect(errors, anyElement(contains('platforms must be an array'))); }); test('unknown platform entry produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(platforms: ['ubuntu', 'solaris']), - ); + final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu', 'solaris'])); expect(errors, anyElement(contains('invalid platform "solaris"'))); }); @@ -250,16 +204,12 @@ void main() { }); test('valid single platform passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(platforms: ['ubuntu']), - ); + final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu'])); expect(errors.where((e) => e.contains('platforms')), isEmpty); }); test('valid multi-platform passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(platforms: ['ubuntu', 'macos', 'windows']), - ); + final errors = WorkflowGenerator.validate(_validConfig(platforms: ['ubuntu', 'macos', 'windows'])); expect(errors.where((e) => e.contains('platforms')), isEmpty); }); @@ -284,61 +234,42 @@ void main() { }); test('valid secrets map passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'API_KEY': 'SOME_SECRET'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'SOME_SECRET'})); expect(errors.where((e) => e.contains('secrets')), isEmpty); }); test('secrets key with hyphen produces error (unsafe identifier)', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'API-KEY': 'SOME_SECRET'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API-KEY': 'SOME_SECRET'})); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets key starting with digit produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'1API_KEY': 'SOME_SECRET'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'1API_KEY': 'SOME_SECRET'})); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets value with hyphen produces error (unsafe secret name)', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'API_KEY': 'SOME-SECRET'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'SOME-SECRET'})); expect(errors, anyElement(contains('safe secret name'))); }); test('secrets key and value with underscore pass', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'API_KEY': 'MY_SECRET_NAME'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'MY_SECRET_NAME'})); expect(errors.where((e) => e.contains('secrets')), isEmpty); }); - test( - 'secrets key with leading underscore produces error (must start with uppercase letter)', - () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'_API_KEY': 'MY_SECRET'}), - ); - expect(errors, anyElement(contains('safe identifier'))); - }, - ); + test('secrets key with leading underscore produces error (must start with uppercase letter)', () { + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'_API_KEY': 'MY_SECRET'})); + expect(errors, anyElement(contains('safe identifier'))); + }); test('secrets key with lowercase produces error (uppercase only)', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'api_key': 'MY_SECRET'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'api_key': 'MY_SECRET'})); expect(errors, anyElement(contains('safe identifier'))); }); test('secrets value with lowercase produces error (uppercase only)', () { - final errors = WorkflowGenerator.validate( - _validConfig(secrets: {'API_KEY': 'my_secret'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(secrets: {'API_KEY': 'my_secret'})); expect(errors, anyElement(contains('safe secret name'))); }); }); @@ -359,18 +290,12 @@ void main() { test('valid pat passes', () { final errors = WorkflowGenerator.validate(_validConfig(pat: 'MY_PAT')); - expect( - errors.where((e) => e.contains('personal_access_token_secret')), - isEmpty, - ); + expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); }); test('null pat is fine (optional, defaults to GITHUB_TOKEN)', () { final errors = WorkflowGenerator.validate(_validConfig()); - expect( - errors.where((e) => e.contains('personal_access_token_secret')), - isEmpty, - ); + expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); }); test('pat with hyphen produces error (unsafe identifier)', () { @@ -379,31 +304,19 @@ void main() { }); test('pat with special chars produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(pat: r'MY_PAT$'), - ); + final errors = WorkflowGenerator.validate(_validConfig(pat: r'MY_PAT$')); expect(errors, anyElement(contains('safe identifier'))); }); test('pat GITHUB_TOKEN passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(pat: 'GITHUB_TOKEN'), - ); - expect( - errors.where((e) => e.contains('personal_access_token_secret')), - isEmpty, - ); + final errors = WorkflowGenerator.validate(_validConfig(pat: 'GITHUB_TOKEN')); + expect(errors.where((e) => e.contains('personal_access_token_secret')), isEmpty); }); - test( - 'pat with leading underscore produces error (must start with uppercase letter)', - () { - final errors = WorkflowGenerator.validate( - _validConfig(pat: '_MY_PAT'), - ); - expect(errors, anyElement(contains('safe identifier'))); - }, - ); + test('pat with leading underscore produces error (must start with uppercase letter)', () { + final errors = WorkflowGenerator.validate(_validConfig(pat: '_MY_PAT')); + expect(errors, anyElement(contains('safe identifier'))); + }); test('pat with lowercase produces error (uppercase only)', () { final errors = WorkflowGenerator.validate(_validConfig(pat: 'my_pat')); @@ -426,30 +339,22 @@ void main() { }); test('git_orgs entry with whitespace produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(gitOrgs: [' open-runtime ']), - ); + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [' open-runtime '])); expect(errors, anyElement(contains('leading/trailing whitespace'))); }); test('git_orgs entry with unsupported characters produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(gitOrgs: ['open/runtime']), - ); + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open/runtime'])); expect(errors, anyElement(contains('unsupported characters'))); }); test('git_orgs duplicate entries produce error', () { - final errors = WorkflowGenerator.validate( - _validConfig(gitOrgs: ['open-runtime', 'open-runtime']), - ); + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'open-runtime'])); expect(errors, anyElement(contains('duplicate org "open-runtime"'))); }); test('valid git_orgs list passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme']), - ); + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme'])); expect(errors.where((e) => e.contains('git_orgs')), isEmpty); }); }); @@ -457,9 +362,7 @@ void main() { // ---- line_length ---- group('line_length', () { test('non-numeric line_length produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: true), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: true)); expect(errors, anyElement(contains('line_length'))); }); @@ -469,9 +372,7 @@ void main() { }); test('string line_length passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '120'), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '120')); expect(errors.where((e) => e.contains('line_length')), isEmpty); }); @@ -481,9 +382,7 @@ void main() { }); test('string line_length "abc" produces error (must be digits only)', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: 'abc'), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: 'abc')); expect(errors, anyElement(contains('digits only'))); }); @@ -492,54 +391,33 @@ void main() { expect(errors, anyElement(contains('must not be empty'))); }); - test( - 'string line_length "+120" produces error (digits only, no sign)', - () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '+120'), - ); - expect(errors, anyElement(contains('digits only'))); - }, - ); + test('string line_length "+120" produces error (digits only, no sign)', () { + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '+120')); + expect(errors, anyElement(contains('digits only'))); + }); - test( - 'string line_length "-120" produces error (digits only, no sign)', - () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '-120'), - ); - expect(errors, anyElement(contains('digits only'))); - }, - ); + test('string line_length "-120" produces error (digits only, no sign)', () { + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '-120')); + expect(errors, anyElement(contains('digits only'))); + }); - test( - 'string line_length with leading/trailing whitespace produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: ' 120 '), - ); - expect(errors, anyElement(contains('whitespace'))); - }, - ); + test('string line_length with leading/trailing whitespace produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(lineLength: ' 120 ')); + expect(errors, anyElement(contains('whitespace'))); + }); test('string line_length with embedded newline produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '12\n0'), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '12\n0')); expect(errors, anyElement(contains('newlines or control'))); }); test('string line_length "0" produces error (out of range)', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '0'), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '0')); expect(errors, anyElement(contains('between 1 and 10000'))); }); test('string line_length "10001" produces error (out of range)', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: '10001'), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: '10001')); expect(errors, anyElement(contains('between 1 and 10000'))); }); @@ -549,9 +427,7 @@ void main() { }); test('int line_length 10001 produces error (out of range)', () { - final errors = WorkflowGenerator.validate( - _validConfig(lineLength: 10001), - ); + final errors = WorkflowGenerator.validate(_validConfig(lineLength: 10001)); expect(errors, anyElement(contains('between 1 and 10000'))); }); }); @@ -561,30 +437,19 @@ void main() { test('int artifact_retention_days passes', () { final config = _validConfig()..['artifact_retention_days'] = 14; final errors = WorkflowGenerator.validate(config); - expect( - errors.where((e) => e.contains('artifact_retention_days')), - isEmpty, - ); + expect(errors.where((e) => e.contains('artifact_retention_days')), isEmpty); }); test('string artifact_retention_days passes', () { final config = _validConfig()..['artifact_retention_days'] = '30'; final errors = WorkflowGenerator.validate(config); - expect( - errors.where((e) => e.contains('artifact_retention_days')), - isEmpty, - ); + expect(errors.where((e) => e.contains('artifact_retention_days')), isEmpty); }); test('artifact_retention_days empty string produces error', () { final config = _validConfig()..['artifact_retention_days'] = ''; final errors = WorkflowGenerator.validate(config); - expect( - errors, - anyElement( - contains('artifact_retention_days string must not be empty'), - ), - ); + expect(errors, anyElement(contains('artifact_retention_days string must not be empty'))); }); test('artifact_retention_days above 90 produces error', () { @@ -604,13 +469,8 @@ void main() { }); test('sub_packages entry that is not a map produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(subPackages: ['just_a_string']), - ); - expect( - errors, - anyElement(contains('sub_packages entries must be objects')), - ); + final errors = WorkflowGenerator.validate(_validConfig(subPackages: ['just_a_string'])); + expect(errors, anyElement(contains('sub_packages entries must be objects'))); }); test('sub_packages with missing name produces error', () { @@ -635,22 +495,16 @@ void main() { expect(errors, anyElement(contains('name must be a non-empty string'))); }); - test( - 'sub_packages with name containing unsupported characters produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo bar', 'path': 'packages/foo'}, - ], - ), - ); - expect( - errors, - anyElement(contains('name contains unsupported characters')), - ); - }, - ); + test('sub_packages with name containing unsupported characters produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo bar', 'path': 'packages/foo'}, + ], + ), + ); + expect(errors, anyElement(contains('name contains unsupported characters'))); + }); test('sub_packages with missing path produces error', () { final errors = WorkflowGenerator.validate( @@ -674,22 +528,16 @@ void main() { expect(errors, anyElement(contains('path must be a non-empty string'))); }); - test( - 'sub_packages path with directory traversal (..) produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': '../../../etc/passwd'}, - ], - ), - ); - expect( - errors, - anyElement(contains('must not traverse outside the repo')), - ); - }, - ); + test('sub_packages path with directory traversal (..) produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': '../../../etc/passwd'}, + ], + ), + ); + expect(errors, anyElement(contains('must not traverse outside the repo'))); + }); test('sub_packages path with embedded traversal produces error', () { final errors = WorkflowGenerator.validate( @@ -699,10 +547,7 @@ void main() { ], ), ); - expect( - errors, - anyElement(contains('must not traverse outside the repo')), - ); + expect(errors, anyElement(contains('must not traverse outside the repo'))); }); test('sub_packages absolute path produces error', () { @@ -771,38 +616,27 @@ void main() { expect(errors, anyElement(contains('unsupported characters'))); }); - test( - 'sub_packages path with leading/trailing whitespace produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': ' packages/foo '}, - ], - ), - ); - expect(errors, anyElement(contains('whitespace'))); - }, - ); + test('sub_packages path with leading/trailing whitespace produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': ' packages/foo '}, + ], + ), + ); + expect(errors, anyElement(contains('whitespace'))); + }); - test( - 'sub_packages name with leading/trailing whitespace produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': ' foo ', 'path': 'packages/foo'}, - ], - ), - ); - expect( - errors, - anyElement( - contains('name must not have leading/trailing whitespace'), - ), - ); - }, - ); + test('sub_packages name with leading/trailing whitespace produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': ' foo ', 'path': 'packages/foo'}, + ], + ), + ); + expect(errors, anyElement(contains('name must not have leading/trailing whitespace'))); + }); test('sub_packages path with trailing tab triggers whitespace error', () { // Trailing \t means trimmed != value, so the whitespace check fires first. @@ -816,20 +650,17 @@ void main() { expect(errors, anyElement(contains('whitespace'))); }); - test( - 'sub_packages path with embedded tab triggers newlines/tabs error', - () { - // Embedded tab: trim() is identity, so newlines/tabs check catches it. - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': 'packages/f\too'}, - ], - ), - ); - expect(errors, anyElement(contains('newlines/tabs'))); - }, - ); + test('sub_packages path with embedded tab triggers newlines/tabs error', () { + // Embedded tab: trim() is identity, so newlines/tabs check catches it. + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': 'packages/f\too'}, + ], + ), + ); + expect(errors, anyElement(contains('newlines/tabs'))); + }); test('sub_packages duplicate name produces error', () { final errors = WorkflowGenerator.validate( @@ -843,20 +674,17 @@ void main() { expect(errors, anyElement(contains('duplicate name "foo"'))); }); - test( - 'sub_packages duplicate path (after normalization) produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - subPackages: [ - {'name': 'foo', 'path': 'packages/foo'}, - {'name': 'bar', 'path': 'packages/./foo'}, - ], - ), - ); - expect(errors, anyElement(contains('duplicate path'))); - }, - ); + test('sub_packages duplicate path (after normalization) produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + subPackages: [ + {'name': 'foo', 'path': 'packages/foo'}, + {'name': 'bar', 'path': 'packages/./foo'}, + ], + ), + ); + expect(errors, anyElement(contains('duplicate path'))); + }); test('valid sub_packages passes', () { final errors = WorkflowGenerator.validate( @@ -882,79 +710,52 @@ void main() { final config = _validConfig(); config['runner_overrides'] = 'invalid'; final errors = WorkflowGenerator.validate(config); - expect( - errors, - anyElement(contains('runner_overrides must be an object')), - ); + expect(errors, anyElement(contains('runner_overrides must be an object'))); }); test('runner_overrides with invalid platform key produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'solaris': 'my-runner'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'solaris': 'my-runner'})); expect(errors, anyElement(contains('invalid platform key "solaris"'))); }); test('runner_overrides with empty string value produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': ''}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': ''})); expect(errors, anyElement(contains('must be a non-empty string'))); }); - test( - 'runner_overrides value with surrounding whitespace produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': ' custom-runner '}), - ); - expect(errors, anyElement(contains('leading/trailing whitespace'))); - }, - ); + test('runner_overrides value with surrounding whitespace produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': ' custom-runner '})); + expect(errors, anyElement(contains('leading/trailing whitespace'))); + }); test('valid runner_overrides passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': 'custom-runner-label'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'custom-runner-label'})); expect(errors.where((e) => e.contains('runner_overrides')), isEmpty); }); test('runner_overrides value with newline produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': 'runner\nlabel'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner\nlabel'})); expect(errors, anyElement(contains('newlines, control chars'))); }); test('runner_overrides value with tab produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': 'runner\tlabel'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner\tlabel'})); expect(errors, anyElement(contains('newlines, control chars'))); }); - test( - 'runner_overrides value with YAML-injection char produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': 'runner:label'}), - ); - expect(errors, anyElement(contains('unsafe YAML chars'))); - }, - ); + test('runner_overrides value with YAML-injection char produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': 'runner:label'})); + expect(errors, anyElement(contains('unsafe YAML chars'))); + }); test('runner_overrides value with dollar sign produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(runnerOverrides: {'ubuntu': r'runner$label'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(runnerOverrides: {'ubuntu': r'runner$label'})); expect(errors, anyElement(contains('unsafe YAML chars'))); }); test('runner_overrides value with hyphen and dot passes', () { final errors = WorkflowGenerator.validate( - _validConfig( - runnerOverrides: {'ubuntu': 'runtime-ubuntu-24.04-x64-256gb'}, - ), + _validConfig(runnerOverrides: {'ubuntu': 'runtime-ubuntu-24.04-x64-256gb'}), ); expect(errors.where((e) => e.contains('runner_overrides')), isEmpty); }); @@ -975,68 +776,49 @@ void main() { }); test('web_test.concurrency non-int produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': 'fast'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 'fast'})); expect(errors, anyElement(contains('concurrency must be an integer'))); }); test('web_test.concurrency zero produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': 0}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 0})); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency negative produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': -1}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': -1})); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency exceeds upper bound produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': 33}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 33})); expect(errors, anyElement(contains('between 1 and 32'))); }); test('web_test.concurrency double/float produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': 3.14}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 3.14})); expect(errors, anyElement(contains('concurrency must be an integer'))); }); test('web_test.concurrency valid int passes', () { final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - webTest: {'concurrency': 4}, - ), + _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}, webTest: {'concurrency': 4}), ); expect(errors.where((e) => e.contains('web_test')), isEmpty); }); test('web_test.concurrency at upper bound (32) passes', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'concurrency': 32}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'concurrency': 32})); expect(errors.where((e) => e.contains('concurrency')), isEmpty); }); test('web_test.concurrency null is fine (defaults to 1)', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {})); expect(errors.where((e) => e.contains('concurrency')), isEmpty); }); test('web_test.paths non-list produces error', () { - final errors = WorkflowGenerator.validate( - _validConfig(webTest: {'paths': 'not_a_list'}), - ); + final errors = WorkflowGenerator.validate(_validConfig(webTest: {'paths': 'not_a_list'})); expect(errors, anyElement(contains('paths must be an array'))); }); @@ -1070,57 +852,42 @@ void main() { }, ), ); - expect( - errors, - anyElement(contains('must not traverse outside the repo')), - ); - }); - - test( - 'web_test.paths with embedded traversal (test/web/../../../etc/passwd) produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - webTest: { - 'paths': ['test/web/../../../etc/passwd'], - }, - ), - ); - expect( - errors, - anyElement(contains('must not traverse outside the repo')), - ); - }, - ); + expect(errors, anyElement(contains('must not traverse outside the repo'))); + }); - test( - 'web_test.paths with shell metacharacters (\$(curl evil)) produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': [r'$(curl evil)'], - }, - ), - ); - expect(errors, anyElement(contains('unsupported characters'))); - }, - ); + test('web_test.paths with embedded traversal (test/web/../../../etc/passwd) produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': true}, + webTest: { + 'paths': ['test/web/../../../etc/passwd'], + }, + ), + ); + expect(errors, anyElement(contains('must not traverse outside the repo'))); + }); - test( - 'web_test.paths with shell metacharacters (; rm -rf /) produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': ['; rm -rf /'], - }, - ), - ); - expect(errors, anyElement(contains('unsupported characters'))); - }, - ); + test('web_test.paths with shell metacharacters (\$(curl evil)) produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': [r'$(curl evil)'], + }, + ), + ); + expect(errors, anyElement(contains('unsupported characters'))); + }); + + test('web_test.paths with shell metacharacters (; rm -rf /) produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': ['; rm -rf /'], + }, + ), + ); + expect(errors, anyElement(contains('unsupported characters'))); + }); test('web_test.paths with single quote produces error', () { final errors = WorkflowGenerator.validate( @@ -1222,22 +989,16 @@ void main() { expect(errors, anyElement(contains('newlines/tabs'))); }); - test( - 'web_test.paths with embedded traversal that escapes repo produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - webTest: { - 'paths': ['test/../../../etc/passwd'], - }, - ), - ); - expect( - errors, - anyElement(contains('must not traverse outside the repo')), - ); - }, - ); + test('web_test.paths with embedded traversal that escapes repo produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + webTest: { + 'paths': ['test/../../../etc/passwd'], + }, + ), + ); + expect(errors, anyElement(contains('must not traverse outside the repo'))); + }); test('web_test.paths with embedded .. that stays in repo is fine', () { // test/web/../../etc/passwd normalizes to etc/passwd (still inside repo) @@ -1332,10 +1093,7 @@ void main() { test('empty web_test.paths list is fine', () { final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - webTest: {'paths': []}, - ), + _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}, webTest: {'paths': []}), ); expect(errors.where((e) => e.contains('web_test')), isEmpty); }); @@ -1360,65 +1118,42 @@ void main() { expect(errors, anyElement(contains('unknown key "concurreny"'))); }); - test( - 'cross-validation: web_test config present but feature disabled produces error', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': false}, - webTest: { - 'concurrency': 2, - 'paths': ['test/web/'], - }, - ), - ); - expect( - errors, - anyElement( - contains( - 'web_test config is present but ci.features.web_test is not enabled', - ), - ), - ); - }, - ); + test('cross-validation: web_test config present but feature disabled produces error', () { + final errors = WorkflowGenerator.validate( + _validConfig( + features: {'proto': false, 'lfs': false, 'web_test': false}, + webTest: { + 'concurrency': 2, + 'paths': ['test/web/'], + }, + ), + ); + expect(errors, anyElement(contains('web_test config is present but ci.features.web_test is not enabled'))); + }); - test( - 'cross-validation: web_test feature enabled but config wrong type produces error', - () { - final config = _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - ); - config['web_test'] = 'yes'; - final errors = WorkflowGenerator.validate(config); - expect(errors, anyElement(contains('web_test must be an object'))); - }, - ); - - test( - 'cross-validation: web_test feature enabled with no config object (null) is allowed, uses defaults', - () { - final errors = WorkflowGenerator.validate( - _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - // webTest: null (omitted) — config is optional when feature is enabled - ), - ); - expect(errors.where((e) => e.contains('web_test')), isEmpty); - }, - ); + test('cross-validation: web_test feature enabled but config wrong type produces error', () { + final config = _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}); + config['web_test'] = 'yes'; + final errors = WorkflowGenerator.validate(config); + expect(errors, anyElement(contains('web_test must be an object'))); + }); - test( - 'cross-validation: web_test feature enabled with explicit null config is allowed', - () { - final config = _validConfig( + test('cross-validation: web_test feature enabled with no config object (null) is allowed, uses defaults', () { + final errors = WorkflowGenerator.validate( + _validConfig( features: {'proto': false, 'lfs': false, 'web_test': true}, - ); - config['web_test'] = null; - final errors = WorkflowGenerator.validate(config); - expect(errors.where((e) => e.contains('web_test')), isEmpty); - }, - ); + // webTest: null (omitted) — config is optional when feature is enabled + ), + ); + expect(errors.where((e) => e.contains('web_test')), isEmpty); + }); + + test('cross-validation: web_test feature enabled with explicit null config is allowed', () { + final config = _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true}); + config['web_test'] = null; + final errors = WorkflowGenerator.validate(config); + expect(errors.where((e) => e.contains('web_test')), isEmpty); + }); }); // ---- fully valid config produces no errors ---- @@ -1476,9 +1211,7 @@ void main() { test('returns null when config.json exists but has no "ci" key', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File( - '${configDir.path}/config.json', - ).writeAsStringSync(json.encode({'repo_name': 'test_repo'})); + File('${configDir.path}/config.json').writeAsStringSync(json.encode({'repo_name': 'test_repo'})); final result = WorkflowGenerator.loadCiConfig(tempDir.path); expect(result, isNull); }); @@ -1502,35 +1235,19 @@ void main() { test('throws StateError on malformed JSON', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File( - '${configDir.path}/config.json', - ).writeAsStringSync('{ not valid json'); + File('${configDir.path}/config.json').writeAsStringSync('{ not valid json'); expect( () => WorkflowGenerator.loadCiConfig(tempDir.path), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('Malformed JSON'), - ), - ), + throwsA(isA().having((e) => e.message, 'message', contains('Malformed JSON'))), ); }); test('throws StateError when "ci" is not a Map', () { final configDir = Directory('${tempDir.path}/.runtime_ci')..createSync(); - File( - '${configDir.path}/config.json', - ).writeAsStringSync(json.encode({'ci': 'not_a_map'})); + File('${configDir.path}/config.json').writeAsStringSync(json.encode({'ci': 'not_a_map'})); expect( () => WorkflowGenerator.loadCiConfig(tempDir.path), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('object'), - ), - ), + throwsA(isA().having((e) => e.message, 'message', contains('object'))), ); }); @@ -1541,10 +1258,7 @@ void main() { 'ci': [1, 2, 3], }), ); - expect( - () => WorkflowGenerator.loadCiConfig(tempDir.path), - throwsA(isA()), - ); + expect(() => WorkflowGenerator.loadCiConfig(tempDir.path), throwsA(isA())); }); }); @@ -1581,58 +1295,37 @@ void main() { } // ---- render() validation guard (defense-in-depth) ---- - test( - 'render throws StateError when config is invalid (missing dart_sdk)', - () { - final gen = WorkflowGenerator( - ciConfig: {'features': {}}, - toolingVersion: '0.0.0-test', - ); - expect( - () => gen.render(), - throwsA( - isA().having( - (e) => e.message, - 'message', - allOf( - contains('Cannot render with invalid config'), - contains('dart_sdk'), - ), - ), + test('render throws StateError when config is invalid (missing dart_sdk)', () { + final gen = WorkflowGenerator(ciConfig: {'features': {}}, toolingVersion: '0.0.0-test'); + expect( + () => gen.render(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('Cannot render with invalid config'), contains('dart_sdk')), ), - ); - }, - ); + ), + ); + }); - test( - 'render throws StateError when config has multiple validation errors', - () { - final gen = WorkflowGenerator( - ciConfig: {}, - toolingVersion: '0.0.0-test', - ); - expect( - () => gen.render(), - throwsA( - isA().having( - (e) => e.message, - 'message', - allOf( - contains('Cannot render with invalid config'), - contains('dart_sdk'), - contains('features'), - ), - ), + test('render throws StateError when config has multiple validation errors', () { + final gen = WorkflowGenerator(ciConfig: {}, toolingVersion: '0.0.0-test'); + expect( + () => gen.render(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('Cannot render with invalid config'), contains('dart_sdk'), contains('features')), ), - ); - }, - ); + ), + ); + }); test('render throws StateError when config has invalid web_test type', () { final gen = WorkflowGenerator( - ciConfig: _validConfig( - features: {'proto': false, 'lfs': false, 'web_test': true}, - )..['web_test'] = 'yes', + ciConfig: _validConfig(features: {'proto': false, 'lfs': false, 'web_test': true})..['web_test'] = 'yes', toolingVersion: '0.0.0-test', ); expect( @@ -1641,20 +1334,14 @@ void main() { isA().having( (e) => e.message, 'message', - allOf( - contains('Cannot render with invalid config'), - contains('web_test must be an object'), - ), + allOf(contains('Cannot render with invalid config'), contains('web_test must be an object')), ), ), ); }); test('render succeeds on valid config', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: false), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: false), toolingVersion: '0.0.0-test'); final rendered = gen.render(); expect(rendered, isNotEmpty); final parsed = loadYaml(rendered) as YamlMap; @@ -1669,34 +1356,22 @@ void main() { }); test('web_test=false: rendered output does not contain web-test job', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: false), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: false), toolingVersion: '0.0.0-test'); final rendered = gen.render(); expect(rendered, isNot(contains('web-test:'))); expect(rendered, isNot(contains('dart test -p chrome'))); }); - test( - 'web_test=true with omitted config uses default concurrency and no explicit paths', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('web-test:')); - expect(rendered, contains('dart test -p chrome')); - expect(rendered, contains('--concurrency=1')); - expect(rendered, contains('Enable Chrome user namespaces on Ubuntu')); - expect( - rendered, - contains('kernel.apparmor_restrict_unprivileged_userns=0'), - ); - expect(rendered, isNot(contains("'test/"))); - }, - ); + test('web_test=true with omitted config uses default concurrency and no explicit paths', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect(rendered, contains('web-test:')); + expect(rendered, contains('dart test -p chrome')); + expect(rendered, contains('--concurrency=1')); + expect(rendered, contains('Enable Chrome user namespaces on Ubuntu')); + expect(rendered, contains('kernel.apparmor_restrict_unprivileged_userns=0')); + expect(rendered, isNot(contains("'test/"))); + }); test('web_test=true with paths: rendered output includes path args', () { final gen = WorkflowGenerator( @@ -1715,27 +1390,18 @@ void main() { expect(rendered, contains('-- \'test/web/foo_test.dart\'')); }); - test( - 'web_test=true with concurrency at upper bound (32): rendered output uses 32', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - webTestConfig: {'concurrency': 32}, - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('--concurrency=32')); - }, - ); - - test('rendered output parses as valid YAML with jobs map', () { + test('web_test=true with concurrency at upper bound (32): rendered output uses 32', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), + ciConfig: _minimalValidConfig(webTest: true, webTestConfig: {'concurrency': 32}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); + expect(rendered, contains('--concurrency=32')); + }); + + test('rendered output parses as valid YAML with jobs map', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); final parsed = loadYaml(rendered) as YamlMap; expect(parsed.containsKey('name'), isTrue); @@ -1758,85 +1424,58 @@ void main() { expect('${firstStep['uses']}', contains('actions/checkout')); }); - test( - 'rendered workflow stays in sync with committed .github/workflows/ci.yaml', - () { - final ciConfig = WorkflowGenerator.loadCiConfig(Directory.current.path); - expect( - ciConfig, - isNotNull, - reason: 'Repository CI config must be present', - ); + test('rendered workflow stays in sync with committed .github/workflows/ci.yaml', () { + final ciConfig = WorkflowGenerator.loadCiConfig(Directory.current.path); + expect(ciConfig, isNotNull, reason: 'Repository CI config must be present'); - final goldenPath = '.github/workflows/ci.yaml'; - final goldenFile = File(goldenPath); - expect( - goldenFile.existsSync(), - isTrue, - reason: 'Committed workflow golden must exist', - ); + final goldenPath = '.github/workflows/ci.yaml'; + final goldenFile = File(goldenPath); + expect(goldenFile.existsSync(), isTrue, reason: 'Committed workflow golden must exist'); - final existingContent = goldenFile.readAsStringSync(); - final toolingVersion = _readToolingVersionFromPubspec(); - final rendered = WorkflowGenerator( - ciConfig: ciConfig!, - toolingVersion: toolingVersion, - ).render(existingContent: existingContent); + final existingContent = goldenFile.readAsStringSync(); + final toolingVersion = _readToolingVersionFromPubspec(); + final rendered = WorkflowGenerator( + ciConfig: ciConfig!, + toolingVersion: toolingVersion, + ).render(existingContent: existingContent); - String normalize(String input) => - '${input.replaceAll('\r\n', '\n').trimRight()}\n'; + String normalize(String input) => '${input.replaceAll('\r\n', '\n').trimRight()}\n'; - expect( - normalize(rendered), - equals(normalize(existingContent)), - reason: - 'Generated workflow drifted from committed file. Re-run workflow generation and commit updated output.', - ); - }, - ); + expect( + normalize(rendered), + equals(normalize(existingContent)), + reason: 'Generated workflow drifted from committed file. Re-run workflow generation and commit updated output.', + ); + }); - test( - 'managed_test: upload step uses success() || failure() not cancelled', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('success() || failure()')); - expect(rendered, isNot(contains('always()'))); - }, - ); - - test( - 'managed_test: Test step has pipefail and tee for correct exit propagation', - () { - // Single-platform and multi-platform must share identical test step - // structure: pipefail ensures test exit code propagates through tee. - final single = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - platforms: ['ubuntu'], - ), - toolingVersion: '0.0.0-test', - ).render(); - final multi = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - platforms: ['ubuntu', 'macos'], - ), - toolingVersion: '0.0.0-test', - ).render(); - for (final rendered in [single, multi]) { - expect(rendered, contains('set -o pipefail')); - expect(rendered, contains('tee "')); - expect(rendered, contains('console.log"')); - expect(rendered, contains('manage_cicd test 2>&1')); - } - }, - ); + test('managed_test: upload step uses success() || failure() not cancelled', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + expect(rendered, contains('success() || failure()')); + expect(rendered, isNot(contains('always()'))); + }); + + test('managed_test: Test step has pipefail and tee for correct exit propagation', () { + // Single-platform and multi-platform must share identical test step + // structure: pipefail ensures test exit code propagates through tee. + final single = WorkflowGenerator( + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu']), + toolingVersion: '0.0.0-test', + ).render(); + final multi = WorkflowGenerator( + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ).render(); + for (final rendered in [single, multi]) { + expect(rendered, contains('set -o pipefail')); + expect(rendered, contains('tee "')); + expect(rendered, contains('console.log"')); + expect(rendered, contains('manage_cicd test 2>&1')); + } + }); test('feature flags render expected snippets', () { final cases = >[ @@ -1844,14 +1483,8 @@ void main() { {'feature': 'lfs', 'snippet': 'lfs: true'}, {'feature': 'format_check', 'snippet': 'auto-format:'}, {'feature': 'analysis_cache', 'snippet': 'Cache Dart analysis'}, - { - 'feature': 'managed_analyze', - 'snippet': 'runtime_ci_tooling:manage_cicd analyze', - }, - { - 'feature': 'managed_test', - 'snippet': 'runtime_ci_tooling:manage_cicd test', - }, + {'feature': 'managed_analyze', 'snippet': 'runtime_ci_tooling:manage_cicd analyze'}, + {'feature': 'managed_test', 'snippet': 'runtime_ci_tooling:manage_cicd test'}, {'feature': 'build_runner', 'snippet': 'Run build_runner'}, ]; @@ -1863,19 +1496,13 @@ void main() { toolingVersion: '0.0.0-test', ); final rendered = gen.render(); - expect( - rendered, - contains(snippet), - reason: 'Feature "$feature" should render "$snippet".', - ); + expect(rendered, contains(snippet), reason: 'Feature "$feature" should render "$snippet".'); } }); test('build_runner=false omits build_runner step', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'build_runner': false}, - ), + ciConfig: _minimalValidConfig(featureOverrides: {'build_runner': false}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -1906,54 +1533,39 @@ void main() { group('render(existingContent) preserves user sections', () { String _normalizeLf(String input) => input.replaceAll('\r\n', '\n'); - test( - 'user section content is preserved when existingContent has custom lines in a user block', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final base = _normalizeLf(gen.render()); - // Append a user block with content so extraction finds it (first occurrence is empty) - const customBlock = ''' + test('user section content is preserved when existingContent has custom lines in a user block', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final base = _normalizeLf(gen.render()); + // Append a user block with content so extraction finds it (first occurrence is empty) + const customBlock = ''' # --- BEGIN USER: pre-test --- - name: Custom pre-test step run: echo "user-added" # --- END USER: pre-test --- '''; - final existing = base + customBlock; - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('Custom pre-test step')); - expect(rendered, contains('user-added')); - expect(rendered, contains('# --- BEGIN USER: pre-test ---')); - expect(rendered, contains('# --- END USER: pre-test ---')); - }, - ); + final existing = base + customBlock; + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('Custom pre-test step')); + expect(rendered, contains('user-added')); + expect(rendered, contains('# --- BEGIN USER: pre-test ---')); + expect(rendered, contains('# --- END USER: pre-test ---')); + }); - test( - 'CRLF normalization: existing content with \\r\\n still preserves sections', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final base = _normalizeLf(gen.render()); - const customContent = '\r\n - run: echo "crlf-test"\r\n'; - final existing = base.replaceFirst( - '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---$customContent# --- END USER: pre-test ---', - ); - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('crlf-test')); - expect(rendered, contains('# --- BEGIN USER: pre-test ---')); - }, - ); + test('CRLF normalization: existing content with \\r\\n still preserves sections', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final base = _normalizeLf(gen.render()); + const customContent = '\r\n - run: echo "crlf-test"\r\n'; + final existing = base.replaceFirst( + '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', + '# --- BEGIN USER: pre-test ---$customContent# --- END USER: pre-test ---', + ); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('crlf-test')); + expect(rendered, contains('# --- BEGIN USER: pre-test ---')); + }); test('multiple user sections preserve independently', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final base = _normalizeLf(gen.render()); var existing = base; existing = existing.replaceFirst( @@ -1975,46 +1587,31 @@ void main() { expect(rendered, contains('runs-on: ubuntu-latest')); }); - test( - 'empty/whitespace-only existing user section does not overwrite rendered section', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final base = _normalizeLf(gen.render()); - // Existing has pre-test with only whitespace; post-test has real content - final existing = base - .replaceFirst( - '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---\n \n \t \n# --- END USER: pre-test ---', - ) - .replaceFirst( - '# --- BEGIN USER: post-test ---\n# --- END USER: post-test ---', - '# --- BEGIN USER: post-test ---\n - run: echo kept\n# --- END USER: post-test ---', - ); - final rendered = gen.render(existingContent: existing); - // pre-test: whitespace-only was skipped, so rendered keeps empty placeholder - expect( - rendered, - contains( + test('empty/whitespace-only existing user section does not overwrite rendered section', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final base = _normalizeLf(gen.render()); + // Existing has pre-test with only whitespace; post-test has real content + final existing = base + .replaceFirst( '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - ), - ); - // post-test: real content was preserved - expect(rendered, contains('echo kept')); - }, - ); + '# --- BEGIN USER: pre-test ---\n \n \t \n# --- END USER: pre-test ---', + ) + .replaceFirst( + '# --- BEGIN USER: post-test ---\n# --- END USER: post-test ---', + '# --- BEGIN USER: post-test ---\n - run: echo kept\n# --- END USER: post-test ---', + ); + final rendered = gen.render(existingContent: existing); + // pre-test: whitespace-only was skipped, so rendered keeps empty placeholder + expect(rendered, contains('# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---')); + // post-test: real content was preserved + expect(rendered, contains('echo kept')); + }); test('unknown section name in existing content is silently ignored', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final base = _normalizeLf(gen.render()); // Add a user section that doesn't exist in the skeleton - final existing = - '$base\n# --- BEGIN USER: nonexistent ---\n custom: stuff\n# --- END USER: nonexistent ---\n'; + final existing = '$base\n# --- BEGIN USER: nonexistent ---\n custom: stuff\n# --- END USER: nonexistent ---\n'; final rendered = gen.render(existingContent: existing); // The unknown section content should not appear in the rendered output // (there's no matching placeholder to insert it into) @@ -2024,10 +1621,7 @@ void main() { }); test('malformed section markers (missing END) are ignored', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final base = _normalizeLf(gen.render()); // Inject a BEGIN without matching END — regex won't match, so it's ignored final existing = base.replaceFirst( @@ -2040,10 +1634,7 @@ void main() { }); test('mismatched section names (BEGIN X / END Y) are ignored', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final base = _normalizeLf(gen.render()); final existing = base.replaceFirst( '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', @@ -2054,36 +1645,24 @@ void main() { expect(rendered, isNot(contains('echo mismatch'))); }); - test( - 'section content with regex-special characters is preserved verbatim', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final base = _normalizeLf(gen.render()); - // Content with regex special chars: $, (), *, +, ?, |, ^, {, } - const specialContent = - r' - run: echo "${{ matrix.os }}" && test [[ "$(whoami)" == "ci" ]]'; - final existing = base.replaceFirst( - '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', - '# --- BEGIN USER: pre-test ---\n$specialContent\n# --- END USER: pre-test ---', - ); - final rendered = gen.render(existingContent: existing); - expect(rendered, contains(specialContent)); - }, - ); + test('section content with regex-special characters is preserved verbatim', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final base = _normalizeLf(gen.render()); + // Content with regex special chars: $, (), *, +, ?, |, ^, {, } + const specialContent = r' - run: echo "${{ matrix.os }}" && test [[ "$(whoami)" == "ci" ]]'; + final existing = base.replaceFirst( + '# --- BEGIN USER: pre-test ---\n# --- END USER: pre-test ---', + '# --- BEGIN USER: pre-test ---\n$specialContent\n# --- END USER: pre-test ---', + ); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains(specialContent)); + }); - test( - 'duplicate user section markers in existing content: last matched section wins', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final base = _normalizeLf(gen.render()); - final existing = - ''' + test('duplicate user section markers in existing content: last matched section wins', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final base = _normalizeLf(gen.render()); + final existing = + ''' $base # --- BEGIN USER: pre-test --- - run: echo first @@ -2092,50 +1671,32 @@ $base - run: echo second # --- END USER: pre-test --- '''; - final rendered = gen.render(existingContent: existing); - expect(rendered, contains('echo second')); - expect(rendered, isNot(contains('echo first'))); - }, - ); + final rendered = gen.render(existingContent: existing); + expect(rendered, contains('echo second')); + expect(rendered, isNot(contains('echo first'))); + }); - test( - 'null existingContent produces same output as no existingContent', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final withoutExisting = gen.render(); - final withNull = gen.render(existingContent: null); - expect(withNull, equals(withoutExisting)); - }, - ); + test('null existingContent produces same output as no existingContent', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final withoutExisting = gen.render(); + final withNull = gen.render(existingContent: null); + expect(withNull, equals(withoutExisting)); + }); - test( - 'existingContent with no user sections produces same output as fresh render', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final fresh = _normalizeLf(gen.render()); - // Use a completely unrelated string as existing content - final rendered = _normalizeLf( - gen.render(existingContent: 'name: SomeOtherWorkflow\non: push'), - ); - expect(rendered, equals(fresh)); - }, - ); + test('existingContent with no user sections produces same output as fresh render', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final fresh = _normalizeLf(gen.render()); + // Use a completely unrelated string as existing content + final rendered = _normalizeLf(gen.render(existingContent: 'name: SomeOtherWorkflow\non: push')); + expect(rendered, equals(fresh)); + }); }); // ---- render() feature flag combinations ---- group('feature flag combinations', () { test('format_check + web_test: web-test needs includes auto-format', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - featureOverrides: {'format_check': true}, - ), + ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'format_check': true}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2148,37 +1709,26 @@ $base test('format_check renders repo-wide dart format command (.)', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'format_check': true}, - ), + ciConfig: _minimalValidConfig(featureOverrides: {'format_check': true}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); expect(rendered, contains('run: dart format --line-length 120 .')); }); - test( - 'git-config steps use env indirection (GH_PAT) instead of inline secrets in run', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('GH_PAT: \${{ secrets.')); - expect(rendered, contains('echo "::add-mask::\${GH_PAT}"')); - expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); - }, - ); + test('git-config steps use env indirection (GH_PAT) instead of inline secrets in run', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect(rendered, contains('GH_PAT: \${{ secrets.')); + expect(rendered, contains('echo "::add-mask::\${GH_PAT}"')); + expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); + }); test('custom git_orgs render configurable org rewrite rules', () { final config = _minimalValidConfig() ..['git_orgs'] = ['acme-runtime'] ..['personal_access_token_secret'] = 'GITHUB_TOKEN'; - final gen = WorkflowGenerator( - ciConfig: config, - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: config, toolingVersion: '0.0.0-test'); final rendered = gen.render(); expect( rendered, @@ -2190,47 +1740,32 @@ $base expect(rendered, isNot(contains('git@github.com:pieces-app/'))); }); - test( - 'web_test without format_check: web-test needs omits auto-format', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('pre-check')); - expect(needs, isNot(contains('auto-format'))); - }, - ); + test('web_test without format_check: web-test needs omits auto-format', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('pre-check')); + expect(needs, isNot(contains('auto-format'))); + }); - test( - 'build_runner + web_test: web-test job contains build_runner step', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - featureOverrides: {'build_runner': true}, - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - // Find the web-test job section and check it contains build_runner - final webTestStart = rendered.indexOf('web-test:'); - expect(webTestStart, isNot(-1)); - final afterWebTest = rendered.substring(webTestStart); - expect(afterWebTest, contains('Run build_runner')); - }, - ); + test('build_runner + web_test: web-test job contains build_runner step', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'build_runner': true}), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + // Find the web-test job section and check it contains build_runner + final webTestStart = rendered.indexOf('web-test:'); + expect(webTestStart, isNot(-1)); + final afterWebTest = rendered.substring(webTestStart); + expect(afterWebTest, contains('Run build_runner')); + }); test('proto + web_test: web-test job contains proto steps', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - featureOverrides: {'proto': true}, - ), + ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'proto': true}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2241,70 +1776,51 @@ $base expect(afterWebTest, contains('Verify proto files')); }); - test( - 'multi-platform + web_test: web-test depends on analyze (not test)', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - platforms: ['ubuntu', 'macos'], - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('analyze')); - expect(needs, isNot(contains('test'))); - expect(needs, isNot(contains('analyze-and-test'))); - }, - ); + test('multi-platform + web_test: web-test depends on analyze (not test)', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('analyze')); + expect(needs, isNot(contains('test'))); + expect(needs, isNot(contains('analyze-and-test'))); + }); - test( - 'single-platform + web_test: web-test depends on analyze-and-test', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final webTestJob = parsed['jobs']['web-test'] as YamlMap; - final needs = (webTestJob['needs'] as YamlList).toList(); - expect(needs, contains('pre-check')); - expect(needs, contains('analyze-and-test')); - }, - ); + test('single-platform + web_test: web-test depends on analyze-and-test', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(webTest: true, platforms: ['ubuntu']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final webTestJob = parsed['jobs']['web-test'] as YamlMap; + final needs = (webTestJob['needs'] as YamlList).toList(); + expect(needs, contains('pre-check')); + expect(needs, contains('analyze-and-test')); + }); - test( - 'single-platform uses explicit PLATFORM_ID from single_platform_id context', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - platforms: ['windows-x64'], - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final job = parsed['jobs']['analyze-and-test'] as YamlMap; - final steps = (job['steps'] as YamlList).toList(); - final testStep = steps.firstWhere( - (s) => s is YamlMap && s['name'] == 'Test', - orElse: () => null, - ); - expect(testStep, isNotNull); - final env = (testStep as YamlMap)['env'] as YamlMap; - expect(env['PLATFORM_ID'], equals('windows-x64')); - }, - ); + test('single-platform uses explicit PLATFORM_ID from single_platform_id context', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['windows-x64']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final job = parsed['jobs']['analyze-and-test'] as YamlMap; + final steps = (job['steps'] as YamlList).toList(); + final testStep = steps.firstWhere((s) => s is YamlMap && s['name'] == 'Test', orElse: () => null); + expect(testStep, isNotNull); + final env = (testStep as YamlMap)['env'] as YamlMap; + expect(env['PLATFORM_ID'], equals('windows-x64')); + }); test('secrets render in web-test job env block', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(webTest: true) - ..['secrets'] = {'API_KEY': 'MY_SECRET'}, + ciConfig: _minimalValidConfig(webTest: true)..['secrets'] = {'API_KEY': 'MY_SECRET'}, toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2317,10 +1833,7 @@ $base test('lfs + web_test: web-test checkout has lfs: true', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - webTest: true, - featureOverrides: {'lfs': true}, - ), + ciConfig: _minimalValidConfig(webTest: true, featureOverrides: {'lfs': true}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2330,28 +1843,19 @@ $base expect(afterWebTest, contains('lfs: true')); }); - test( - 'managed_test in multi-platform: test job uses managed test command', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - platforms: ['ubuntu', 'macos'], - ), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final parsed = loadYaml(rendered) as YamlMap; - final testJob = parsed['jobs']['test'] as YamlMap; - final steps = (testJob['steps'] as YamlList).toList(); - final testStep = steps.firstWhere( - (s) => s is YamlMap && s['name'] == 'Test', - orElse: () => null, - ); - expect(testStep, isNotNull); - expect((testStep as YamlMap)['run'], contains('manage_cicd test')); - }, - ); + test('managed_test in multi-platform: test job uses managed test command', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}, platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final parsed = loadYaml(rendered) as YamlMap; + final testJob = parsed['jobs']['test'] as YamlMap; + final steps = (testJob['steps'] as YamlList).toList(); + final testStep = steps.firstWhere((s) => s is YamlMap && s['name'] == 'Test', orElse: () => null); + expect(testStep, isNotNull); + expect((testStep as YamlMap)['run'], contains('manage_cicd test')); + }); test('all features enabled renders valid YAML', () { final gen = WorkflowGenerator( @@ -2393,10 +1897,7 @@ $base }); test('no features enabled (all false) renders minimal valid YAML', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final rendered = gen.render(); final parsed = loadYaml(rendered) as YamlMap; final jobs = parsed['jobs'] as YamlMap; @@ -2429,8 +1930,7 @@ $base test('runner_overrides change runs-on in single-platform', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig() - ..['runner_overrides'] = {'ubuntu': 'my-custom-runner'}, + ciConfig: _minimalValidConfig()..['runner_overrides'] = {'ubuntu': 'my-custom-runner'}, toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2441,9 +1941,7 @@ $base test('artifact retention-days policy applied consistently (7 days)', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig( - featureOverrides: {'managed_test': true}, - ), + ciConfig: _minimalValidConfig(featureOverrides: {'managed_test': true}), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); @@ -2451,59 +1949,39 @@ $base expect(rendered, contains('Policy: test artifact retention-days = 7')); }); - test( - 'artifact retention-days can be overridden via ci.artifact_retention_days', - () { - final ci = _minimalValidConfig( - featureOverrides: {'managed_test': true}, - ); - ci['artifact_retention_days'] = 14; - final gen = WorkflowGenerator( - ciConfig: ci, - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('retention-days: 14')); - expect( - rendered, - contains('Policy: test artifact retention-days = 14'), - ); - }, - ); - - test( - 'Windows pub-cache path uses format for Dart default (%LOCALAPPDATA%\\Pub\\Cache)', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(platforms: ['windows']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - expect(rendered, contains('Pub')); - expect(rendered, contains('Cache')); - expect(rendered, contains('env.LOCALAPPDATA')); - expect(rendered, contains("'~/.pub-cache'")); - expect(rendered, contains("runner.os == 'Windows'")); - }, - ); + test('artifact retention-days can be overridden via ci.artifact_retention_days', () { + final ci = _minimalValidConfig(featureOverrides: {'managed_test': true}); + ci['artifact_retention_days'] = 14; + final gen = WorkflowGenerator(ciConfig: ci, toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect(rendered, contains('retention-days: 14')); + expect(rendered, contains('Policy: test artifact retention-days = 14')); + }); - // Issue #12: shared step partials — verify dedup behavior - test('shared partials are resolved (no raw partial tags in output)', () { + test('Windows pub-cache path uses format for Dart default (%LOCALAPPDATA%\\Pub\\Cache)', () { final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), + ciConfig: _minimalValidConfig(platforms: ['windows']), toolingVersion: '0.0.0-test', ); final rendered = gen.render(); + expect(rendered, contains('Pub')); + expect(rendered, contains('Cache')); + expect(rendered, contains('env.LOCALAPPDATA')); + expect(rendered, contains("'~/.pub-cache'")); + expect(rendered, contains("runner.os == 'Windows'")); + }); + + // Issue #12: shared step partials — verify dedup behavior + test('shared partials are resolved (no raw partial tags in output)', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); expect(rendered, isNot(contains('<%> shared_'))); expect(rendered, isNot(contains('shared_setup_through_build'))); expect(rendered, isNot(contains('shared_analysis_block'))); }); test('shared step markers from partials appear in rendered output', () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(), - toolingVersion: '0.0.0-test', - ); + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); final rendered = gen.render(); expect(rendered, contains('# ── shared:checkout ──')); expect(rendered, contains('# ── shared:git-config ──')); @@ -2511,29 +1989,23 @@ $base expect(rendered, contains('# ── shared:pub-cache ──')); }); - test( - 'multi-platform jobs use same shared setup partial (analyze and test)', - () { - final gen = WorkflowGenerator( - ciConfig: _minimalValidConfig(platforms: ['ubuntu', 'macos']), - toolingVersion: '0.0.0-test', - ); - final rendered = gen.render(); - final analyzeIdx = rendered.indexOf(' analyze:'); - final testIdx = rendered.indexOf(' test:'); - expect(analyzeIdx, isNot(-1)); - expect(testIdx, isNot(-1)); - final analyzeSection = rendered.substring(analyzeIdx, testIdx); - final testSection = rendered.substring(testIdx); - expect( - analyzeSection, - contains('Configure Git for HTTPS with Token'), - ); - expect(testSection, contains('Configure Git for HTTPS with Token')); - expect(analyzeSection, contains('Cache Dart pub dependencies')); - expect(testSection, contains('Cache Dart pub dependencies')); - }, - ); + test('multi-platform jobs use same shared setup partial (analyze and test)', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final analyzeIdx = rendered.indexOf(' analyze:'); + final testIdx = rendered.indexOf(' test:'); + expect(analyzeIdx, isNot(-1)); + expect(testIdx, isNot(-1)); + final analyzeSection = rendered.substring(analyzeIdx, testIdx); + final testSection = rendered.substring(testIdx); + expect(analyzeSection, contains('Configure Git for HTTPS with Token')); + expect(testSection, contains('Configure Git for HTTPS with Token')); + expect(analyzeSection, contains('Cache Dart pub dependencies')); + expect(testSection, contains('Cache Dart pub dependencies')); + }); }); }); }