diff --git a/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs new file mode 100644 index 000000000..1bf59b528 --- /dev/null +++ b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using GitHub.Runner.Sdk; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Phase a step occupies in the runner's flat execution sequence. + /// Setup and Cleanup are NOT modeled here — they are synthetic + /// boundaries hard-coded by + /// and cannot be constructed by callers. + /// + internal enum JobExecutionPhase + { + Pre, + Main, + Post, + } + + /// + /// One step in the rendered execution view. Pure data; no link to + /// any worker type. Phase 2 will translate runner step objects + /// into instances of this record. + /// + internal sealed class JobExecutionViewEntry + { + public JobExecutionViewEntry( + JobExecutionPhase phase, + string displayName, + string uses = null, + string run = null, + string sourcePath = null, + int sourceLine = 0, + string id = null, + string @if = null, + string continueOnError = null, + string timeoutMinutes = null, + string envYaml = null, + string withYaml = null, + string shell = null, + string workingDirectory = null) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + throw new ArgumentException("displayName must not be null or whitespace.", nameof(displayName)); + } + if (sourcePath != null && sourceLine < 1) + { + throw new ArgumentException( + "sourceLine must be >= 1 when sourcePath is provided.", + nameof(sourceLine)); + } + + Phase = phase; + DisplayName = displayName; + Uses = uses; + Run = run; + SourcePath = sourcePath; + SourceLine = sourceLine; + Id = id; + If = @if; + ContinueOnError = continueOnError; + TimeoutMinutes = timeoutMinutes; + EnvYaml = envYaml; + WithYaml = withYaml; + Shell = shell; + WorkingDirectory = workingDirectory; + } + + public JobExecutionPhase Phase { get; } + public string DisplayName { get; } + public string Uses { get; } + public string Run { get; } + public string SourcePath { get; } + public int SourceLine { get; } + public string Id { get; } + public string If { get; } + public string ContinueOnError { get; } + public string TimeoutMinutes { get; } + // Pre-serialized YAML fragment, already indented for embedding + // under the entry's `env:` key (6-space child indent). + public string EnvYaml { get; } + public string WithYaml { get; } + public string Shell { get; } + public string WorkingDirectory { get; } + + /// + /// Set when the corresponding step was skipped (e.g. predicted Post + /// placeholder for a Main step that never executed because its + /// if: evaluated false). Rendered as an inline YAML comment + /// on the entry's - step: line so subsequent entry line + /// numbers stay stable. + /// + public bool IsSkipped { get; internal set; } + } + + /// + /// Output of : the YAML + /// document plus a parallel array of 1-based line numbers, one per + /// input entry, where each entry's - step: key appears. + /// Synthetic Setup/Cleanup boundaries are not tracked here. + /// + internal readonly struct RenderResult + { + public RenderResult(string yaml, IReadOnlyList entryStartLines) + { + Yaml = yaml; + EntryStartLines = entryStartLines; + } + + public string Yaml { get; } + public IReadOnlyList EntryStartLines { get; } + } + + /// + /// Renders a job's execution-view YAML. Pure function; no I/O, + /// no logging, no static state. Output format and Setup/Cleanup + /// boundaries are fixed; callers cannot influence them. + /// + /// Output is structured as phase-keyed top-level sections: + /// setup:, pre:, main:, post:, cleanup:. + /// setup: and cleanup: always render; pre:, + /// main:, post: only render when they contain at least + /// one entry. + /// + internal static class JobExecutionViewRenderer + { + public static RenderResult Render(string jobId, IReadOnlyList entries) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId)); + } + ArgUtil.NotNull(entries, nameof(entries)); + + // Pre-validate non-null entries before any output, so partial + // state is never observed by callers. + for (int i = 0; i < entries.Count; i++) + { + if (entries[i] == null) + { + throw new ArgumentException($"entries[{i}] is null.", nameof(entries)); + } + } + + var sb = new StringBuilder(); + var startLines = new int[entries.Count]; + int newlinesEmitted = 0; + + // Header (3 lines). + sb.Append("# Job: ").Append(FormatScalar(jobId)).Append('\n'); + sb.Append("# Runner execution plan — read-only.\n"); + sb.Append('\n'); + newlinesEmitted += 3; + + // setup: section — always present. + sb.Append("setup:\n"); + sb.Append(" - step: Setup job\n"); + newlinesEmitted += 2; + + // Render phase sections in fixed order. Each emits a leading + // blank line separator before its header. + EmitPhaseSection(sb, "pre", JobExecutionPhase.Pre, entries, startLines, ref newlinesEmitted); + EmitPhaseSection(sb, "main", JobExecutionPhase.Main, entries, startLines, ref newlinesEmitted); + EmitPhaseSection(sb, "post", JobExecutionPhase.Post, entries, startLines, ref newlinesEmitted); + + // cleanup: section — always present, preceded by a blank line. + sb.Append('\n'); + sb.Append("cleanup:\n"); + sb.Append(" - step: Complete job\n"); + + return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines)); + } + + private static void EmitPhaseSection( + StringBuilder sb, + string sectionName, + JobExecutionPhase phase, + IReadOnlyList entries, + int[] startLines, + ref int newlinesEmitted) + { + // Skip the section entirely if no entries belong to this phase. + bool any = false; + for (int i = 0; i < entries.Count; i++) + { + if (entries[i].Phase == phase) { any = true; break; } + } + if (!any) + { + return; + } + + // Blank line separator + section header. + sb.Append('\n'); + sb.Append(sectionName).Append(":\n"); + newlinesEmitted += 2; + + for (int i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (entry.Phase != phase) + { + continue; + } + + // 1-based line of the `- step:` key for this entry. + startLines[i] = newlinesEmitted + 1; + + sb.Append(" - step: ").Append(FormatScalar(entry.DisplayName)); + if (entry.IsSkipped) + { + // Inline comment — keeps following entry line numbers stable. + sb.Append(" # (skipped — main step did not execute)"); + } + sb.Append('\n'); + newlinesEmitted++; + + switch (phase) + { + case JobExecutionPhase.Pre: + case JobExecutionPhase.Post: + if (!string.IsNullOrEmpty(entry.Uses)) + { + sb.Append(" action: ").Append(FormatScalar(entry.Uses)).Append('\n'); + newlinesEmitted++; + } + // No source: annotation for pre/post. + break; + + case JobExecutionPhase.Main: + if (!string.IsNullOrEmpty(entry.Id)) + { + sb.Append(" id: ").Append(FormatScalar(entry.Id)).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.Uses)) + { + sb.Append(" uses: ").Append(FormatScalar(entry.Uses)).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.Run)) + { + if (entry.Run.IndexOf('\n') < 0) + { + sb.Append(" run: ").Append(FormatScalar(entry.Run)).Append('\n'); + newlinesEmitted++; + } + else + { + sb.Append(" run: |\n"); + newlinesEmitted++; + newlinesEmitted += AppendIndentedBlock(sb, entry.Run, " "); + } + } + if (!string.IsNullOrEmpty(entry.If)) + { + sb.Append(" if: ").Append(FormatScalar(entry.If)).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.ContinueOnError)) + { + sb.Append(" continue-on-error: ").Append(entry.ContinueOnError).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.TimeoutMinutes)) + { + sb.Append(" timeout-minutes: ").Append(entry.TimeoutMinutes).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.EnvYaml)) + { + sb.Append(" env:\n"); + newlinesEmitted++; + sb.Append(entry.EnvYaml).Append('\n'); + newlinesEmitted += CountChar(entry.EnvYaml, '\n') + 1; + } + if (!string.IsNullOrEmpty(entry.WithYaml)) + { + sb.Append(" with:\n"); + newlinesEmitted++; + sb.Append(entry.WithYaml).Append('\n'); + newlinesEmitted += CountChar(entry.WithYaml, '\n') + 1; + } + if (!string.IsNullOrEmpty(entry.Shell)) + { + sb.Append(" shell: ").Append(FormatScalar(entry.Shell)).Append('\n'); + newlinesEmitted++; + } + if (!string.IsNullOrEmpty(entry.WorkingDirectory)) + { + sb.Append(" working-directory: ").Append(FormatScalar(entry.WorkingDirectory)).Append('\n'); + newlinesEmitted++; + } + if (entry.SourcePath != null) + { + sb.Append(" source: ") + .Append(entry.SourcePath) + .Append(':') + .Append(entry.SourceLine.ToString(CultureInfo.InvariantCulture)) + .Append('\n'); + newlinesEmitted++; + } + break; + } + } + } + + private static int AppendIndentedBlock(StringBuilder sb, string text, string indent) + { + int newlines = 0; + int i = 0; + while (i < text.Length) + { + int end = text.IndexOf('\n', i); + int lineEnd = end < 0 ? text.Length : end; + int trimEnd = lineEnd; + if (trimEnd > i && text[trimEnd - 1] == '\r') + { + trimEnd--; + } + if (trimEnd > i) + { + sb.Append(indent); + sb.Append(text, i, trimEnd - i); + } + sb.Append('\n'); + newlines++; + if (end < 0) + { + break; + } + i = end + 1; + } + return newlines; + } + + private static int CountChar(string s, char c) + { + int n = 0; + for (int i = 0; i < s.Length; i++) + { + if (s[i] == c) n++; + } + return n; + } + + /// + /// Formats a single string as a YAML 1.x flow scalar, delegating + /// quoting/escaping decisions to YamlDotNet. This avoids maintaining + /// our own escape table for every YAML-significant character: we + /// just emit the value through the YAML library and use whichever + /// scalar style (plain, single-quoted, double-quoted) it picks. + /// A new is created per call, so the helper + /// is safe to invoke concurrently. + /// + internal static string FormatScalar(string value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + using var sw = new StringWriter(CultureInfo.InvariantCulture); + var emitter = new Emitter(sw); + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart(null, null, true)); + emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true)); + emitter.Emit(new DocumentEnd(true)); + emitter.Emit(new StreamEnd()); + + string raw = sw.ToString(); + if (raw.StartsWith("--- ", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + raw = raw.TrimEnd('\n'); + const string DocEndMarker = "\n..."; + if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length); + } + return raw.TrimEnd('\n'); + } + } +} diff --git a/src/Test/L0/Worker/JobExecutionViewRendererL0.cs b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs new file mode 100644 index 000000000..02d42d940 --- /dev/null +++ b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs @@ -0,0 +1,617 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class JobExecutionViewRendererL0 + { + // Verbatim expected YAML for the design doc's "Worked example". + // The render output is structured as phase-keyed top-level sections; + // there is no per-entry `phase:` field. The setup: and cleanup: + // sections always render; pre:/main:/post: render only when + // they contain at least one entry. The Main entries surface + // user-authored step parameters pre-evaluation (no expression + // substitution); Pre/Post entries stay minimal. + private const string ExpectedWorkedExampleYaml = + "# Job: build\n" + + "# Runner execution plan — read-only.\n" + + "\n" + + "setup:\n" + + " - step: Setup job\n" + + "\n" + + "pre:\n" + + " - step: Pre actions/checkout@v4\n" + + " action: actions/checkout@v4\n" + + " - step: Pre actions/cache@v5\n" + + " action: actions/cache@v5\n" + + "\n" + + "main:\n" + + " - step: actions/checkout@v4\n" + + " uses: actions/checkout@v4\n" + + " source: .github/workflows/ci.yml:10\n" + + " - step: Cache Primes\n" + + " id: cache-primes\n" + + " uses: actions/cache@v5\n" + + " with:\n" + + " path: prime-numbers\n" + + " key: ${{ runner.os }}-primes\n" + + " source: .github/workflows/ci.yml:12\n" + + " - step: Run tests\n" + + " id: test\n" + + " run: |\n" + + " echo starting\n" + + " npm test\n" + + " if: ${{ github.event_name == 'push' }}\n" + + " env:\n" + + " NODE_ENV: production\n" + + " shell: bash\n" + + " working-directory: ./api\n" + + " source: .github/workflows/ci.yml:18\n" + + " - step: npm ci\n" + + " run: npm ci\n" + + " source: .github/workflows/ci.yml:28\n" + + "\n" + + "post:\n" + + " - step: Post actions/cache@v5\n" + + " action: actions/cache@v5\n" + + " - step: Post actions/checkout@v4\n" + + " action: actions/checkout@v4\n" + + "\n" + + "cleanup:\n" + + " - step: Complete job\n"; + + private static List WorkedExampleEntries() + { + return new List + { + new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/checkout@v4", uses: "actions/checkout@v4"), + new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/cache@v5", uses: "actions/cache@v5"), + new JobExecutionViewEntry(JobExecutionPhase.Main, "actions/checkout@v4", uses: "actions/checkout@v4", sourcePath: ".github/workflows/ci.yml", sourceLine: 10), + new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Cache Primes", + uses: "actions/cache@v5", + id: "cache-primes", + withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes", + sourcePath: ".github/workflows/ci.yml", + sourceLine: 12), + new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Run tests", + run: "echo starting\nnpm test", + id: "test", + @if: "${{ github.event_name == 'push' }}", + envYaml: " NODE_ENV: production", + shell: "bash", + workingDirectory: "./api", + sourcePath: ".github/workflows/ci.yml", + sourceLine: 18), + new JobExecutionViewEntry(JobExecutionPhase.Main, "npm ci", run: "npm ci", sourcePath: ".github/workflows/ci.yml", sourceLine: 28), + new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/cache@v5", uses: "actions/cache@v5"), + new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/checkout@v4", uses: "actions/checkout@v4"), + }; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_MatchesDesignDocWorkedExample() + { + var entries = WorkedExampleEntries(); + + var result = JobExecutionViewRenderer.Render("build", entries); + + Assert.Equal(ExpectedWorkedExampleYaml, result.Yaml); + Assert.Equal(8, result.EntryStartLines.Count); + var lines = result.Yaml.Split('\n'); + for (int i = 0; i < entries.Count; i++) + { + Assert.StartsWith(" - step: ", lines[result.EntryStartLines[i] - 1]); + Assert.Contains(entries[i].DisplayName, lines[result.EntryStartLines[i] - 1]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_AlwaysEmitsSetupAndCleanup() + { + var result = JobExecutionViewRenderer.Render("job-1", new List()); + + const string expected = + "# Job: job-1\n" + + "# Runner execution plan — read-only.\n" + + "\n" + + "setup:\n" + + " - step: Setup job\n" + + "\n" + + "cleanup:\n" + + " - step: Complete job\n"; + Assert.Equal(expected, result.Yaml); + Assert.Empty(result.EntryStartLines); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_OmitsEmptyOptionalSections() + { + // Only a Main entry — pre:/post: must not appear. + var result = JobExecutionViewRenderer.Render("j", new[] + { + new JobExecutionViewEntry(JobExecutionPhase.Main, "echo", run: "echo hello"), + }); + + Assert.Contains("setup:\n", result.Yaml); + Assert.Contains("main:\n", result.Yaml); + Assert.Contains("cleanup:\n", result.Yaml); + Assert.DoesNotContain("\npre:\n", result.Yaml); + Assert.DoesNotContain("\npost:\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsPhaseSectionsInFixedOrder() + { + // Input order [Post, Pre, Main] should still render as setup → pre → main → post → cleanup. + var entries = new[] + { + new JobExecutionViewEntry(JobExecutionPhase.Post, "post-a", uses: "a/b@v1"), + new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-a", uses: "a/b@v1"), + new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a/b@v1"), + }; + + var result = JobExecutionViewRenderer.Render("j", entries); + string yaml = result.Yaml; + + int setupIdx = yaml.IndexOf("setup:\n", StringComparison.Ordinal); + int preIdx = yaml.IndexOf("\npre:\n", StringComparison.Ordinal); + int mainIdx = yaml.IndexOf("\nmain:\n", StringComparison.Ordinal); + int postIdx = yaml.IndexOf("\npost:\n", StringComparison.Ordinal); + int cleanupIdx = yaml.IndexOf("\ncleanup:\n", StringComparison.Ordinal); + Assert.True(setupIdx >= 0 && preIdx > setupIdx && mainIdx > preIdx && postIdx > mainIdx && cleanupIdx > postIdx, + $"section ordering wrong: setup={setupIdx} pre={preIdx} main={mainIdx} post={postIdx} cleanup={cleanupIdx}"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_StartLinesAlignWithInputOrder() + { + // Input order is [Pre, Main, Post]; output order is also pre/main/post, + // but startLines must be indexed by INPUT position, not by section. + var entries = new[] + { + new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-x", uses: "x/y@v1"), // index 0 + new JobExecutionViewEntry(JobExecutionPhase.Main, "main-x", uses: "x/y@v1"), // index 1 + new JobExecutionViewEntry(JobExecutionPhase.Post, "post-x", uses: "x/y@v1"), // index 2 + }; + + var result = JobExecutionViewRenderer.Render("j", entries); + var lines = result.Yaml.Split('\n'); + + Assert.StartsWith(" - step: pre-x", lines[result.EntryStartLines[0] - 1]); + Assert.StartsWith(" - step: main-x", lines[result.EntryStartLines[1] - 1]); + Assert.StartsWith(" - step: post-x", lines[result.EntryStartLines[2] - 1]); + // And input-order ordering of start lines is strictly increasing + // when phases are in declaration order matching the section order. + Assert.True(result.EntryStartLines[0] < result.EntryStartLines[1]); + Assert.True(result.EntryStartLines[1] < result.EntryStartLines[2]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_StartLinesFollowInputOrderEvenWhenPhasesAreInterleaved() + { + // Input order is [Main A, Pre B, Main C]: pre section will render + // first (Pre B) and main second (Main A then Main C). startLines + // must still be indexed by input order. + var entries = new[] + { + new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a@v1"), // index 0 — renders in main section + new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-b", uses: "b@v1"), // index 1 — renders in pre section + new JobExecutionViewEntry(JobExecutionPhase.Main, "main-c", uses: "c@v1"), // index 2 — renders in main section + }; + + var result = JobExecutionViewRenderer.Render("j", entries); + var lines = result.Yaml.Split('\n'); + + Assert.StartsWith(" - step: main-a", lines[result.EntryStartLines[0] - 1]); + Assert.StartsWith(" - step: pre-b", lines[result.EntryStartLines[1] - 1]); + Assert.StartsWith(" - step: main-c", lines[result.EntryStartLines[2] - 1]); + // The pre section comes before main: input-index-1 entry's line is + // before input-index-0 entry's line. + Assert.True(result.EntryStartLines[1] < result.EntryStartLines[0]); + Assert.True(result.EntryStartLines[0] < result.EntryStartLines[2]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EntryStartLinesPointAtStepKeys() + { + var entries = WorkedExampleEntries(); + var result = JobExecutionViewRenderer.Render("build", entries); + var lines = result.Yaml.Split('\n'); + + for (int i = 0; i < result.EntryStartLines.Count; i++) + { + int oneBased = result.EntryStartLines[i]; + Assert.True(oneBased >= 1 && oneBased <= lines.Length, $"start line {oneBased} out of range"); + Assert.StartsWith(" - step: ", lines[oneBased - 1]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EntryStartLinesExcludeSetupAndCleanup() + { + var entries = WorkedExampleEntries(); + var result = JobExecutionViewRenderer.Render("build", entries); + var lines = result.Yaml.Split('\n'); + + int setupLine = -1, cleanupLine = -1; + for (int i = 0; i < lines.Length; i++) + { + if (lines[i] == " - step: Setup job") setupLine = i + 1; + if (lines[i] == " - step: Complete job") cleanupLine = i + 1; + } + Assert.True(setupLine > 0 && cleanupLine > 0, "Setup/Cleanup lines must exist"); + Assert.DoesNotContain(setupLine, result.EntryStartLines); + Assert.DoesNotContain(cleanupLine, result.EntryStartLines); + } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("hello")] + [InlineData("with: colon")] + [InlineData("with#hash")] + [InlineData(" leading")] + [InlineData("trailing ")] + [InlineData("a\"b")] + [InlineData("a\\b")] + [InlineData("@at")] + [InlineData("*star")] + public void Render_QuotesSpecialChars(string displayName) + { + // Round-trip the rendered YAML through YamlDotNet's deserializer + // and assert the parsed step's display name matches the input. + // This decouples the test from any specific quoting style. + var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, displayName); + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build(); + var doc = deserializer.Deserialize>>>(result.Yaml); + Assert.NotNull(doc); + Assert.True(doc.ContainsKey("main"), "rendered YAML missing top-level 'main' key"); + var mainSteps = doc["main"]; + Assert.Single(mainSteps); + Assert.Equal(displayName, mainSteps[0]["step"] as string); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsSourceAnnotationForMainStep() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "npm ci", + run: "npm ci", + sourcePath: ".github/workflows/ci.yml", + sourceLine: 42); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + Assert.Contains(" source: .github/workflows/ci.yml:42\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_OmitsSourceAnnotationForPreAndPost() + { + var pre = new JobExecutionViewEntry( + JobExecutionPhase.Pre, + "Pre actions/checkout@v4", + uses: "actions/checkout@v4", + sourcePath: ".github/workflows/ci.yml", + sourceLine: 9); + var post = new JobExecutionViewEntry( + JobExecutionPhase.Post, + "Post actions/checkout@v4", + uses: "actions/checkout@v4", + sourcePath: ".github/workflows/ci.yml", + sourceLine: 9); + + var result = JobExecutionViewRenderer.Render("j", new[] { pre, post }); + + Assert.DoesNotContain("source:", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsMultilineRunAsBlockScalar() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "multi", + run: "echo a\necho b\necho c"); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + Assert.Contains(" run: |\n", result.Yaml); + Assert.Contains(" echo a\n", result.Yaml); + Assert.Contains(" echo b\n", result.Yaml); + Assert.Contains(" echo c\n", result.Yaml); + Assert.DoesNotContain("truncated", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsAllUserAuthoredParamsForActionStep() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Run action", + uses: "actions/cache@v5", + id: "cache-primes", + @if: "${{ github.event_name == 'push' }}", + continueOnError: "true", + timeoutMinutes: "10", + envYaml: " NODE_ENV: production", + withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes", + sourcePath: "ci.yml", + sourceLine: 5); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + Assert.Contains(" id: cache-primes\n", result.Yaml); + Assert.Contains(" uses: actions/cache@v5\n", result.Yaml); + Assert.Contains(" continue-on-error: true\n", result.Yaml); + Assert.Contains(" timeout-minutes: 10\n", result.Yaml); + Assert.Contains(" env:\n NODE_ENV: production\n", result.Yaml); + Assert.Contains(" with:\n path: prime-numbers\n key: ${{ runner.os }}-primes\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsRunStepWithShellAndWorkingDirectory() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Run tests", + run: "echo starting\nnpm test", + id: "test", + shell: "bash", + workingDirectory: "./api"); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + Assert.Contains(" run: |\n echo starting\n npm test\n", result.Yaml); + Assert.Contains(" shell: bash\n", result.Yaml); + Assert.Contains(" working-directory: ./api\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_PreservesExpressionsInRenderedYaml() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Cache", + uses: "actions/cache@v5", + withYaml: " key: ${{ runner.os }}-primes"); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + // Expressions render exactly as authored — no evaluation. + Assert.Contains("${{ runner.os }}-primes", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_PrePostStepsRemainMinimal() + { + // Even if a pre/post entry carries user-param fields (it shouldn't + // in production, but the renderer must defensively drop them), + // only step: + action: render for these phases. + var pre = new JobExecutionViewEntry( + JobExecutionPhase.Pre, + "Pre actions/cache@v5", + uses: "actions/cache@v5", + id: "should-not-appear", + envYaml: " X: y", + withYaml: " key: nope"); + var post = new JobExecutionViewEntry( + JobExecutionPhase.Post, + "Post actions/cache@v5", + uses: "actions/cache@v5", + id: "should-not-appear", + envYaml: " X: y"); + + var result = JobExecutionViewRenderer.Render("j", new[] { pre, post }); + + Assert.DoesNotContain("id:", result.Yaml); + Assert.DoesNotContain("env:", result.Yaml); + Assert.DoesNotContain("with:", result.Yaml); + Assert.DoesNotContain("should-not-appear", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_FieldOrderIsStable() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "Everything", + uses: "actions/cache@v5", + id: "x", + @if: "always()", + continueOnError: "false", + timeoutMinutes: "5", + envYaml: " A: 1", + withYaml: " key: k", + sourcePath: "ci.yml", + sourceLine: 1); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + var y = result.Yaml; + int iStep = y.IndexOf(" - step: ", StringComparison.Ordinal) >= 0 + ? y.IndexOf("- step:", StringComparison.Ordinal) : y.IndexOf("- step:", StringComparison.Ordinal); + int iId = y.IndexOf(" id:", StringComparison.Ordinal); + int iUses = y.IndexOf(" uses:", StringComparison.Ordinal); + int iIf = y.IndexOf(" if:", StringComparison.Ordinal); + int iCoe = y.IndexOf(" continue-on-error:", StringComparison.Ordinal); + int iTm = y.IndexOf(" timeout-minutes:", StringComparison.Ordinal); + int iEnv = y.IndexOf(" env:", StringComparison.Ordinal); + int iWith = y.IndexOf(" with:", StringComparison.Ordinal); + int iSrc = y.IndexOf(" source:", StringComparison.Ordinal); + Assert.True(iId < iUses && iUses < iIf && iIf < iCoe && iCoe < iTm && iTm < iEnv && iEnv < iWith && iWith < iSrc, + $"order wrong: id={iId} uses={iUses} if={iIf} coe={iCoe} tm={iTm} env={iEnv} with={iWith} src={iSrc}"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_OmitsEmptyOptionalFields() + { + var entry = new JobExecutionViewEntry( + JobExecutionPhase.Main, + "bare", + uses: "a/b@v1"); + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + Assert.DoesNotContain(" id:", result.Yaml); + Assert.DoesNotContain(" if:", result.Yaml); + Assert.DoesNotContain(" continue-on-error:", result.Yaml); + Assert.DoesNotContain(" timeout-minutes:", result.Yaml); + Assert.DoesNotContain(" env:", result.Yaml); + Assert.DoesNotContain(" with:", result.Yaml); + Assert.DoesNotContain(" shell:", result.Yaml); + Assert.DoesNotContain(" working-directory:", result.Yaml); + Assert.DoesNotContain(" source:", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_HandlesEmptyEntries() + { + var result = JobExecutionViewRenderer.Render("j", new List()); + + Assert.Empty(result.EntryStartLines); + Assert.Contains(" - step: Setup job\n", result.Yaml); + Assert.Contains(" - step: Complete job\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_NoPerEntryPhaseField() + { + // The phase: per-entry field is gone — the section + // header is the phase indicator. Guard against accidental + // regressions. + var result = JobExecutionViewRenderer.Render("build", WorkedExampleEntries()); + Assert.DoesNotContain("phase:", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_ThrowsOnNullJobId() + { + Assert.Throws( + () => JobExecutionViewRenderer.Render(null, new List())); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_ThrowsOnWhitespaceJobId() + { + Assert.Throws( + () => JobExecutionViewRenderer.Render(" ", new List())); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_ThrowsOnNullEntries() + { + Assert.Throws( + () => JobExecutionViewRenderer.Render("j", null)); + } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData(null, 1)] + [InlineData("", 1)] + [InlineData(" ", 1)] + public void Entry_Constructor_RejectsBadDisplayName(string displayName, int sourceLine) + { + Assert.Throws( + () => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet() + { + Assert.Throws( + () => new JobExecutionViewEntry( + JobExecutionPhase.Main, + "ok", + sourcePath: "ci.yml", + sourceLine: 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_EmitsSkippedAnnotationForMarkedEntry() + { + var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1"); + entry.IsSkipped = true; + + var result = JobExecutionViewRenderer.Render("j", new[] { entry }); + + // Annotation is inline on the `- step:` line so subsequent + // entry line numbers stay stable. + Assert.Contains("- step: Post X # (skipped — main step did not execute)\n", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_SkippedAnnotation_DoesNotShiftSubsequentLines() + { + var skipped = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post A", uses: "actions/a@v1"); + var following = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post B", uses: "actions/b@v1"); + + var unmarked = JobExecutionViewRenderer.Render("j", new[] { skipped, following }); + skipped.IsSkipped = true; + var marked = JobExecutionViewRenderer.Render("j", new[] { skipped, following }); + + // Following entry's start line must not move when the prior + // entry gets an inline skipped annotation. + Assert.Equal(unmarked.EntryStartLines[1], marked.EntryStartLines[1]); + } + } +}