Compare commits

..

4 Commits

Author SHA1 Message Date
Salman Chishti
7ce1ad10e4 Merge branch 'main' into copilot/fix-ci-issues-and-upgrade-dependencies 2026-05-01 15:57:25 +01:00
copilot-swe-agent[bot]
9825a12fd1 fix: restore properly-bundled hashFiles index.js and patch GetNextMessageAsync mock
- Restore src/Misc/layoutbin/hashFiles/index.js from commit b06c585 so
  @actions/glob is bundled inline by ncc rather than externalized. The
  previous regeneration was done without running npm install first, causing
  ncc to fall back to require("@actions/glob") which is not available at
  runtime.

- Fix flaky GetNextMessageAsync mock in TestRunAsync: after Task.Delay
  resolves (either by timeout or cancellation) return null when the queue
  is empty instead of calling Dequeue() on an empty queue which would
  throw InvalidOperationException.

Agent-Logs-Url: https://github.com/actions/runner/sessions/9ceb7e98-b518-4b84-b302-29b0ed28500b
2026-04-29 15:13:19 +00:00
copilot-swe-agent[bot]
fabb1364f5 refactor: clear ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED once at class level
Implement IDisposable on RunnerL0 to save/clear the env var in the
constructor and restore it in Dispose(). This means each xUnit test
instance (which gets its own RunnerL0 instance) automatically runs
with the env var unset, making all tests environment-independent.

Remove the per-test try/finally from TestRunAsync and revert the 5
run-once tests back to plain Assert.Equal(Success) assertions.

Agent-Logs-Url: https://github.com/actions/runner/sessions/210e24f3-d6ee-4270-81e5-34774fd75bc4

Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>
2026-04-29 15:01:17 +00:00
copilot-swe-agent[bot]
ac1b1bc95b fix: resolve CI failures in dependency PRs
- Fix 6 failing RunnerL0 tests caused by ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED
  env var being set on GitHub-hosted runners (introduced by PR #4233):
  - TestRunAsync: clear env var so non-ephemeral test runs with runOnce=false
  - 5 run-once tests: accept both return code 0 and 100 (TaskResult.Succeeded)
- Bump System.Security.Cryptography.Pkcs 10.0.6->10.0.7 (PR #4369)
- Bump System.Formats.Asn1 10.0.6->10.0.7 (PR #4369)
- Bump System.Security.Cryptography.ProtectedData 10.0.3->10.0.6 (PRs #4363)
- Bump System.Text.Encoding.CodePages 10.0.3->10.0.6 (PR #4364)
- Bump System.ServiceProcess.ServiceController 10.0.3->10.0.7 (PR #4376)
- Bump Microsoft.DevTunnels.Connections 1.3.39->1.3.43 (PR #4375)
- Bump @typescript-eslint/eslint-plugin ^8.59.0->^8.59.1 (PR #4382)
- Bump @typescript-eslint/parser ^8.59.0->^8.59.1 (PR #4381)
- Fix @stylistic/func-call-spacing -> function-call-spacing in eslintrc
- Add types:[node] to tsconfig.json for TypeScript 6 compatibility

Agent-Logs-Url: https://github.com/actions/runner/sessions/4df39c13-5067-4ae3-bcbc-eacf08a30722
2026-04-28 12:59:48 +00:00
36 changed files with 364 additions and 5553 deletions

View File

@@ -26,7 +26,7 @@
],
"camelcase": "off",
"@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
"@stylistic/func-call-spacing": ["error", "never"],
"@stylistic/function-call-spacing": ["error", "never"],
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "error",

View File

@@ -14,8 +14,8 @@
"devDependencies": {
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^4.10.2",
@@ -359,16 +359,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
"integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/type-utils": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/type-utils": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -381,7 +382,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/parser": "^8.59.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -409,15 +410,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -433,13 +435,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
"integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.0",
"@typescript-eslint/types": "^8.59.0",
"@typescript-eslint/tsconfig-utils": "^8.59.1",
"@typescript-eslint/types": "^8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -454,13 +457,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
"integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0"
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -471,10 +475,11 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
"integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -487,14 +492,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
"integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -515,6 +521,7 @@
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
@@ -523,10 +530,11 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
"integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -536,15 +544,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
"integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.0",
"@typescript-eslint/tsconfig-utils": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/project-service": "8.59.1",
"@typescript-eslint/tsconfig-utils": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -567,6 +576,7 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
@@ -576,6 +586,7 @@
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
@@ -588,6 +599,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
@@ -603,6 +615,7 @@
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
@@ -611,15 +624,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
"integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0"
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -634,12 +648,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
"integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -655,6 +670,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -2186,6 +2202,24 @@
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4250,6 +4284,7 @@
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
@@ -4261,28 +4296,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -4946,16 +4965,16 @@
}
},
"@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
"integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/type-utils": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/type-utils": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -4971,61 +4990,59 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"requires": {}
"dev": true
}
}
},
"@typescript-eslint/parser": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3"
}
},
"@typescript-eslint/project-service": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
"integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.59.0",
"@typescript-eslint/types": "^8.59.0",
"@typescript-eslint/tsconfig-utils": "^8.59.1",
"@typescript-eslint/types": "^8.59.1",
"debug": "^4.4.3"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
"integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0"
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
"integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
"dev": true,
"requires": {}
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"dev": true
},
"@typescript-eslint/type-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
"integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -5034,27 +5051,26 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"requires": {}
"dev": true
}
}
},
"@typescript-eslint/types": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
"integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
"integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.59.0",
"@typescript-eslint/tsconfig-utils": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/project-service": "8.59.1",
"@typescript-eslint/tsconfig-utils": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -5090,30 +5106,29 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"requires": {}
"dev": true
}
}
},
"@typescript-eslint/utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
"integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0"
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
"integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"eslint-visitor-keys": "^5.0.0"
},
"dependencies": {
@@ -5147,8 +5162,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"requires": {}
"dev": true
},
"ajv": {
"version": "6.14.0",
@@ -5705,8 +5719,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz",
"integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==",
"dev": true,
"requires": {}
"dev": true
},
"eslint-import-resolver-node": {
"version": "0.3.9",
@@ -5940,8 +5953,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18n-text/-/eslint-plugin-i18n-text-1.0.1.tgz",
"integrity": "sha512-3G3UetST6rdqhqW9SfcfzNYMpQXS7wNkJvp6dsXnjzGiku6Iu5hl3B0kmk6lIcFPwYjhQIY+tXVRtK9TlGT7RA==",
"dev": true,
"requires": {}
"dev": true
},
"eslint-plugin-import": {
"version": "2.28.1",
@@ -6185,6 +6197,12 @@
"reusify": "^1.0.4"
}
},
"fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true
},
"file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -7573,13 +7591,6 @@
"picomatch": "^4.0.4"
},
"dependencies": {
"fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"requires": {}
},
"picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -7607,8 +7618,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
"integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
"dev": true,
"requires": {}
"dev": true
},
"tsconfig-paths": {
"version": "3.14.2",

View File

@@ -37,8 +37,8 @@
"devDependencies": {
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^4.10.2",

View File

@@ -6,7 +6,8 @@
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"types": ["node"] /* Include Node.js type definitions. */
},
"exclude": ["node_modules", "**/*.test.ts"]
}

View File

@@ -17,8 +17,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.6" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.6" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>

View File

@@ -22,7 +22,7 @@
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.6" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
</ItemGroup>

View File

@@ -12,6 +12,8 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
{
public class CheckoutTask : IRunnerActionPlugin
{
private readonly Regex _validSha1 = new(@"\b[0-9a-f]{40}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
{
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
@@ -97,7 +99,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_0
{
sourceBranch = refInput;
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
{
sourceVersion = sourceBranch;

View File

@@ -96,7 +96,7 @@ namespace GitHub.Runner.Plugins.Repository.v1_1
{
sourceBranch = refInput;
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.CommitHash))
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
{
sourceVersion = sourceBranch;
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*

View File

@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.6" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>

View File

@@ -16,10 +16,19 @@ using Microsoft.DevTunnels.Connections;
using Microsoft.DevTunnels.Contracts;
using Microsoft.DevTunnels.Management;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stores information about a completed step for stack trace display.
/// </summary>
internal sealed class CompletedStepInfo
{
public string DisplayName { get; set; }
public TaskResult? Result { get; set; }
public int FrameId { get; set; }
}
/// <summary>
/// Single public facade for the Debug Adapter Protocol subsystem.
/// Owns the full transport, handshake, step-level pauses, variable
@@ -42,12 +51,8 @@ namespace GitHub.Runner.Worker.Dap
// Frame ID for the current step (always 1)
private const int _currentFrameId = 1;
// Frame ID for the static "job" frame anchored at line 1 of the execution view.
private const int _jobFrameId = 2;
// MVP serves a single synthesized source per session (the job's execution view).
// Stable session-scoped ID 1; future sources (composite step-in) will use higher IDs.
private const int _executionViewSourceReference = 1;
// Frame IDs for completed steps start at 1000
private const int _completedFrameIdBase = 1000;
private TcpListener _listener;
private TcpClient _client;
@@ -87,6 +92,11 @@ namespace GitHub.Runner.Worker.Dap
// Current execution context
private IStep _currentStep;
private IExecutionContext _jobContext;
private int _currentStepIndex;
// Track completed steps for stack trace
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
private int _nextCompletedFrameId = _completedFrameIdBase;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
@@ -97,8 +107,6 @@ namespace GitHub.Runner.Worker.Dap
// REPL command executor for run() commands
private DapReplExecutor _replExecutor;
private JobExecutionView _executionView;
public bool IsActive =>
_state == DapSessionState.Ready ||
_state == DapSessionState.Paused ||
@@ -235,37 +243,17 @@ namespace GitHub.Runner.Worker.Dap
{
if (_state != DapSessionState.NotStarted)
{
// Pause so the user can inspect final job state before we tear down,
// but only if the user was stepping through (not if they hit continue).
if (IsActive && _pauseOnNextStep)
{
try
{
if (_jobContext != null)
{
Trace.Info("Job completed — pausing for inspection");
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
await WaitForCommandAsync(_jobContext.CancellationToken);
}
}
catch (Exception ex)
{
Trace.Warning("DAP job-completed pause error.");
Trace.Error(ex);
}
}
try
{
OnJobCompleted();
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobCompleted error.");
Trace.Error(ex);
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
}
}
await StopAsync();
}
public async Task StopAsync()
@@ -380,8 +368,7 @@ namespace GitHub.Runner.Worker.Dap
}
catch (Exception ex)
{
Trace.Warning("DAP OnStepStarting error.");
Trace.Error(ex);
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
}
}
@@ -394,8 +381,10 @@ namespace GitHub.Runner.Worker.Dap
try
{
var result = step.ExecutionContext?.Result;
Trace.Info("Step completed");
JobExecutionView view;
// Add to completed steps list for stack trace
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
@@ -405,353 +394,20 @@ namespace GitHub.Runner.Worker.Dap
return;
}
// Clear current-step ref if it matches; otherwise leave alone
// (defensive — OnStepStartingAsync may have already advanced it).
if (ReferenceEquals(_currentStep, step))
_completedSteps.Add(new CompletedStepInfo
{
_currentStep = null;
}
view = _executionView;
}
// If the skipped step was a Main IActionRunner with a predicted
// Post-step placeholder, mark that placeholder as skipped so
// the view does not advertise a step that will never run.
if (view != null &&
step is IActionRunner actionRunner &&
actionRunner.Stage == ActionRunStage.Main &&
actionRunner.Action != null &&
step.ExecutionContext?.Result == TaskResult.Skipped)
{
var matchKey = MatchKeyFor(actionRunner.Action.Id);
if (view.TryMarkSkipped(matchKey))
{
SendLoadedSourceEvent("changed");
}
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
});
}
}
catch (Exception ex)
{
Trace.Warning("DAP OnStepCompleted error.");
Trace.Error(ex);
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
}
}
/// <summary>
/// Snapshot of the current job execution view, or null if it has not
/// been built yet (debugger inactive, or InitializeJob has not yet
/// signalled). Phase 2c will consume this for DAP source/stack-trace
/// responses.
/// </summary>
internal JobExecutionView ExecutionView
{
get
{
lock (_stateLock)
{
return _executionView;
}
}
}
public async Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack)
{
if (!IsActive)
{
return;
}
try
{
IExecutionContext jobContext;
lock (_stateLock)
{
jobContext = _jobContext;
}
string jobId = jobContext?.GetGitHubContext("job");
if (string.IsNullOrWhiteSpace(jobId))
{
jobId = "job";
}
// Materialize mainQueue once so we can iterate it twice
// (once for entries, once for the post-step predictor).
var mainSteps = mainQueue == null ? new List<IStep>() : new List<IStep>(mainQueue);
var entries = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>();
foreach (var step in mainSteps)
{
var entry = StepEntryTranslator.TryTranslate(step);
if (entry != null)
{
entries.Add((entry, step));
}
}
// Stack<T>.GetEnumerator() yields items in LIFO order — the
// same order callers will pop them. We materialize them into
// the view in that pop order so post-step entries appear in
// execution order.
if (initialPostStack != null)
{
foreach (var step in initialPostStack)
{
var entry = StepEntryTranslator.TryTranslate(step);
if (entry != null)
{
entries.Add((entry, step));
}
}
}
var view = new JobExecutionView(jobId);
if (entries.Count > 0)
{
view.AppendRange(entries);
}
// Predict Post-step placeholders for actions that declare
// HasPost in their action manifest. Walking Pre+Main runners
// in declaration order, then prepending each prediction so
// the rendered post section matches the runner's LIFO
// post-execution order (the runner's post stack pops in
// reverse-registration order). Wrapped in a try/catch so a
// missing IActionManager or LoadAction failure cannot
// prevent the view from being published.
try
{
PredictPostPlaceholders(jobContext, mainSteps, view);
}
catch (Exception ex)
{
Trace.Warning("DAP predictor: predicting post placeholders failed; continuing without predictions.");
Trace.Error(ex);
}
lock (_stateLock)
{
_executionView = view;
}
Trace.Info($"DAP execution view initialized with {view.EntryCount} entries.");
SendLoadedSourceEvent("new");
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error.");
Trace.Error(ex);
}
await Task.CompletedTask;
}
public void OnPostStepRegistered(IStep step)
{
if (!IsActive || step == null)
{
return;
}
try
{
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
if (view == null)
{
return;
}
// Try to claim a previously-predicted placeholder. When
// OnJobStepsInitializedAsync ran, we walked the Pre+Main
// queue and synthesized a Post placeholder for every action
// whose manifest declared HasPost. If this registration
// matches one of those placeholders by Action.Id, claim it
// in place — no view growth, no `loadedSource changed`
// event needed.
if (step is IActionRunner postRunner && postRunner.Action != null)
{
var matchKey = MatchKeyFor(postRunner.Action.Id);
if (view.TryClaim(matchKey, step).HasValue)
{
return;
}
}
// Unpredicted path: composite-action JIT post discovery,
// container hooks, or any other registration we did not
// foresee at view-build time. Fall back to append + notify
// clients via `loadedSource changed`.
var entry = StepEntryTranslator.TryTranslate(step);
if (entry == null)
{
return;
}
try
{
view.Append(entry, step);
SendLoadedSourceEvent("changed");
}
catch (InvalidOperationException ex)
{
// Step already registered — RegisterPostJobStep tolerates
// duplicate registrations in some workflow shapes; mirror
// that semantics here so we don't propagate.
Trace.Info($"DAP OnPostStepRegistered: duplicate step ignored ({ex.Message}).");
}
}
catch (Exception ex)
{
Trace.Warning("DAP OnPostStepRegistered error.");
Trace.Error(ex);
}
}
/// <summary>
/// Walks <paramref name="mainSteps"/> (the queue of Pre+Main
/// IActionRunners produced by JobRunner) and synthesizes a Post
/// placeholder entry on <paramref name="view"/> for every action
/// whose manifest declares <c>HasPost = true</c>.
///
/// Conditions mirror <c>ActionRunner.RunAsync</c> exactly:
/// the runner is in Pre or Main stage, the action is a
/// <see cref="Pipelines.RepositoryPathReference"/> that is NOT the
/// self-repository alias, the action is not a script, and the
/// resolved <see cref="Definition"/> reports HasPost.
///
/// Predictions are collected in declaration order, then APPENDED
/// in reverse so the rendered post section mirrors the runner's
/// LIFO post-execution order (the runner's post stack pops in
/// reverse-registration order — see ExecutionContext.RegisterPostJobStep).
/// </summary>
private void PredictPostPlaceholders(IExecutionContext jobContext, IReadOnlyList<IStep> mainSteps, JobExecutionView view)
{
if (jobContext == null || mainSteps == null || mainSteps.Count == 0 || view == null)
{
return;
}
IActionManager actionManager;
try
{
actionManager = HostContext.GetService<IActionManager>();
}
catch (Exception ex)
{
Trace.Info($"DAP predictor: IActionManager unavailable ({ex.Message}); skipping post-step prediction.");
return;
}
var predictions = new List<(JobExecutionViewEntry entry, string matchKey)>();
var seenActionIds = new HashSet<Guid>();
foreach (var step in mainSteps)
{
if (step is not IActionRunner runner)
{
continue;
}
if (runner.Stage == ActionRunStage.Post)
{
// Post entries are already seeded from initialPostStack.
continue;
}
var action = runner.Action;
if (action == null)
{
continue;
}
// ActionRunner.cs:113 — Post only created when the action is
// a RepositoryPathReference that is not the self-repository
// alias and not a script.
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
{
continue;
}
if (string.Equals(repoRef.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// (A RepositoryPathReference is never a script — script
// run-steps surface as a different ActionStepDefinitionReference
// subclass, so the cast above already filtered them out.)
// Dedupe by Action.Id: the runtime dedups via
// Root.StepsWithPostRegistered.Add(actionRunner.Action.Id),
// so two steps referencing the same Action.Id only ever
// register one Post. Mirror that here so we don't synthesize
// two placeholders for one future registration.
if (!seenActionIds.Add(action.Id))
{
continue;
}
Definition definition;
try
{
definition = actionManager.LoadAction(jobContext, action);
}
catch (Exception ex)
{
Trace.Info($"DAP predictor: LoadAction failed for {repoRef.Name} ({ex.Message}); skipping prediction.");
continue;
}
if (definition?.Data?.Execution?.HasPost != true)
{
continue;
}
// Compute the Post display name exactly as ActionRunner does
// when it constructs the Post IActionRunner (ActionRunner.cs:115-122).
var displayName = runner.DisplayName;
if (string.IsNullOrEmpty(displayName))
{
displayName = "step";
}
if (runner.Stage == ActionRunStage.Pre &&
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
{
displayName = displayName.Substring("Pre ".Length);
}
var postDisplayName = $"Post {displayName}";
var entry = new JobExecutionViewEntry(
phase: JobExecutionPhase.Post,
displayName: postDisplayName,
uses: StepEntryTranslator.FormatActionReference(action.Reference));
predictions.Add((entry, MatchKeyFor(action.Id)));
}
// Reverse declaration order so the rendered post section
// matches the LIFO order in which the runner will pop posts.
predictions.Reverse();
foreach (var (entry, key) in predictions)
{
try
{
view.Append(entry, stepIdentity: null, matchKey: key);
}
catch (Exception ex)
{
Trace.Warning("DAP predictor: failed to append Post placeholder; skipping.");
Trace.Error(ex);
}
}
}
// Stable, opaque key derived from an action's Pipelines.ActionStep.Id.
// All IActionRunner instances for the same action (Pre/Main/Post)
// share the same Action reference (see ActionRunner.cs:131), so the
// Id is constant across phases and is the right join key.
private static string MatchKeyFor(Guid actionId) =>
$"post:{actionId:N}";
internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
{
Request request = null;
@@ -793,8 +449,6 @@ namespace GitHub.Runner.Worker.Dap
"next" => HandleNext(request),
"setBreakpoints" => HandleSetBreakpoints(request),
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
"source" => HandleSource(request),
"loadedSources" => HandleLoadedSources(request),
"completions" => HandleCompletions(request),
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
@@ -1148,7 +802,7 @@ namespace GitHub.Runner.Worker.Dap
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
{
bool shouldPause;
bool pauseOnNextStep;
CancellationToken cancellationToken;
lock (_stateLock)
{
@@ -1160,14 +814,18 @@ namespace GitHub.Runner.Worker.Dap
}
_currentStep = step;
_currentStepIndex = _completedSteps.Count;
pauseOnNextStep = _pauseOnNextStep;
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
shouldPause = ShouldPauseBefore(step, isFirstStep);
}
// Reset variable references so stale nested refs from the
// previous step are not served to the client.
_variableProvider?.Reset();
// Determine if we should pause
bool shouldPause = isFirstStep || pauseOnNextStep;
if (!shouldPause)
{
Trace.Info("Step starting without debugger pause");
@@ -1188,29 +846,6 @@ namespace GitHub.Runner.Worker.Dap
await WaitForCommandAsync(cancellationToken);
}
/// <summary>
/// Decides whether the debugger should pause before <paramref name="step"/>.
/// Today: pause on the first step always; otherwise pause when the user
/// has elected step-mode (the 'next' command). Future breakpoint support
/// will be a single additional check here against a per-step breakpoint set.
/// Caller MUST hold <c>_stateLock</c>.
/// </summary>
private bool ShouldPauseBefore(IStep step, bool isFirstStep)
{
if (isFirstStep)
{
return true;
}
if (_pauseOnNextStep)
{
return true;
}
// TODO Phase 2c+1: if (_breakpointSet.Contains(step)) return true;
return false;
}
internal void OnJobCompleted()
{
Trace.Info("Job completed, sending terminated event");
@@ -1282,7 +917,7 @@ namespace GitHub.Runner.Worker.Dap
SupportsTerminateRequest = false,
SupportTerminateDebuggee = false,
SupportsDelayedStackTraceLoading = false,
SupportsLoadedSourcesRequest = true,
SupportsLoadedSourcesRequest = false,
SupportsProgressReporting = false,
SupportsRunInTerminalRequest = false,
SupportsCancelRequest = false,
@@ -1356,155 +991,72 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: body);
}
internal Response HandleStackTrace(Request request)
private Response HandleStackTrace(Request request)
{
IStep currentStep;
JobExecutionView view;
int currentStepIndex;
CompletedStepInfo[] completedSteps;
lock (_stateLock)
{
currentStep = _currentStep;
view = _executionView;
currentStepIndex = _currentStepIndex;
completedSteps = _completedSteps.ToArray();
}
var frames = new List<StackFrame>();
if (view != null)
// Add current step as the top frame
if (currentStep != null)
{
var source = BuildExecutionViewSource(view.JobId);
var resultIndicator = currentStep.ExecutionContext?.Result != null
? $" [{currentStep.ExecutionContext.Result}]"
: " [running]";
// Frame 0: the currently-executing step (only when one is set).
if (currentStep != null)
{
var stepLine = view.TryGetLineForStep(currentStep) ?? 1;
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"),
Line = stepLine,
Column = 1,
Source = source,
PresentationHint = "normal",
});
}
// Frame 1: the job (anchors the stack; line 1 = the synthesized header).
frames.Add(new StackFrame
{
Id = _jobFrameId,
Name = MaskUserVisibleText($"job: {view.JobId}"),
Line = 1,
Column = 1,
Source = source,
PresentationHint = "subtle",
});
}
else if (currentStep != null)
{
// Defensive: view not yet built but a step is executing.
// Still emit a single frame with no Source so the client doesn't choke.
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"),
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
Line = currentStepIndex + 1,
Column = 1,
PresentationHint = "normal"
});
}
else
{
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = "(no step executing)",
Line = 0,
Column = 1,
PresentationHint = "subtle"
});
}
// Add completed steps as additional frames (most recent first)
for (int i = completedSteps.Length - 1; i >= 0; i--)
{
var completedStep = completedSteps[i];
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
frames.Add(new StackFrame
{
Id = completedStep.FrameId,
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
Line = 1,
Column = 1,
PresentationHint = "normal",
PresentationHint = "subtle"
});
}
var body = new StackTraceResponseBody
{
StackFrames = frames,
TotalFrames = frames.Count,
TotalFrames = frames.Count
};
return CreateResponse(request, true, body: body);
}
/// <summary>
/// Builds the synthesized job execution view <see cref="Source"/> descriptor.
/// All frames in a session share one Source; the client retrieves its
/// content via the DAP <c>source</c> request keyed by <see cref="_executionViewSourceReference"/>.
/// </summary>
private Source BuildExecutionViewSource(string jobId)
{
return new Source
{
Name = MaskUserVisibleText("execution.yml"),
Path = MaskUserVisibleText($"{jobId}/execution.yml"),
SourceReference = _executionViewSourceReference,
PresentationHint = "normal",
};
}
internal Response HandleSource(Request request)
{
SourceArguments args;
try
{
args = request.Arguments?.ToObject<SourceArguments>();
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
return CreateResponse(request, false, "Invalid source arguments.", body: null);
}
if (args == null)
{
return CreateResponse(request, false, "Missing source arguments.", body: null);
}
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
if (view == null)
{
return CreateResponse(request, false, "Execution view not yet available.", body: null);
}
if (args.SourceReference != _executionViewSourceReference)
{
return CreateResponse(request, false, $"Unknown source reference: {args.SourceReference}.", body: null);
}
var body = new SourceResponseBody
{
Content = MaskUserVisibleText(view.Yaml),
// MimeType intentionally unset: VS Code's debug content provider
// short-circuits language detection on the response's mimeType
// (exact-match against its registered language mimetypes) and
// falls back to plaintext on unknown values. The IANA YAML type
// "application/yaml" is not in VS Code's table (it only knows
// the legacy "text/x-yaml" synthesized for the built-in YAML
// language contribution). By omitting mimeType, clients fall
// through to path-extension detection — `.yml` in Source.Path
// is the universal mechanism every DAP client honors
// consistently (VS Code, nvim-dap, JetBrains).
};
return CreateResponse(request, true, body: body);
}
internal Response HandleLoadedSources(Request request)
{
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
var body = new LoadedSourcesResponseBody();
if (view != null)
{
body.Sources.Add(BuildExecutionViewSource(view.JobId));
}
return CreateResponse(request, true, body: body);
}
private Response HandleScopes(Request request)
{
var args = request.Arguments?.ToObject<ScopesArguments>();
@@ -1720,40 +1272,11 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: null);
}
internal Response HandleSetBreakpoints(Request request)
private Response HandleSetBreakpoints(Request request)
{
SetBreakpointsArguments args = null;
try
{
args = request.Arguments?.ToObject<SetBreakpointsArguments>();
}
catch (Exception ex)
{
Trace.Warning($"Failed to parse setBreakpoints arguments: {ex.GetType().Name}");
}
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
var body = new SetBreakpointsResponseBody();
if (args?.Breakpoints != null && view != null)
{
var source = BuildExecutionViewSource(view.JobId);
foreach (var requested in args.Breakpoints)
{
body.Breakpoints.Add(new Breakpoint
{
Verified = false,
Line = requested.Line,
Source = source,
Message = "Breakpoint support is coming in a future runner release. The debugger currently pauses at every step boundary; use 'continue' to advance.",
});
}
}
return CreateResponse(request, true, body: body);
// MVP: acknowledge but don't process breakpoints
// All steps pause automatically via _pauseOnNextStep
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
}
private Response HandleSetExceptionBreakpoints(Request request)
@@ -1779,13 +1302,6 @@ namespace GitHub.Runner.Worker.Dap
_commandTcs = new TaskCompletionSource<DapCommand>(TaskCreationOptions.RunContinuationsAsynchronously);
}
// If cancellation already fired before we created the new TCS,
// the registration callback targeted the old one. Unblock now.
if (cancellationToken.IsCancellationRequested)
{
_commandTcs.TrySetResult(DapCommand.Disconnect);
}
Trace.Info("Waiting for debugger command...");
var command = await _commandTcs.Task;
@@ -1818,7 +1334,8 @@ namespace GitHub.Runner.Worker.Dap
/// <summary>
/// Resolves the execution context for a given stack frame ID.
/// Frame 1 = current step; frame 2 = job-level (subtle anchor frame).
/// Frame 1 = current step; frames 1000+ = completed steps (no
/// context available - those steps have already finished).
/// Falls back to the job-level context when no step is active.
/// </summary>
private IExecutionContext GetExecutionContextForFrame(int frameId)
@@ -1828,7 +1345,7 @@ namespace GitHub.Runner.Worker.Dap
return GetCurrentExecutionContext();
}
// Job/anchor frame — no step-level context.
// Completed-step frames don't carry a live execution context.
return null;
}
@@ -1865,33 +1382,6 @@ namespace GitHub.Runner.Worker.Dap
});
}
/// <summary>
/// Sends a loadedSource event with the current execution view's source.
/// No-op if the view has not been built yet.
/// </summary>
private void SendLoadedSourceEvent(string reason)
{
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
if (view == null)
{
return;
}
SendEvent(new Event
{
EventType = "loadedSource",
Body = new LoadedSourceEventBody
{
Reason = reason,
Source = BuildExecutionViewSource(view.JobId),
},
});
}
private string MaskUserVisibleText(string value)
{
if (string.IsNullOrEmpty(value))

View File

@@ -537,132 +537,6 @@ namespace GitHub.Runner.Worker.Dap
#endregion
#region Source Request/Response
/// <summary>
/// Arguments for 'source' request.
/// </summary>
public class SourceArguments
{
/// <summary>
/// Source descriptor (optional, redundant with sourceReference).
/// </summary>
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
/// <summary>
/// The reference to the source. Required by DAP spec.
/// </summary>
[JsonProperty("sourceReference")]
public int SourceReference { get; set; }
}
/// <summary>
/// Response body for 'source' request.
/// </summary>
public class SourceResponseBody
{
/// <summary>
/// Content of the source as a string.
/// </summary>
[JsonProperty("content")]
public string Content { get; set; }
/// <summary>
/// Optional content type / mime type of the source.
/// </summary>
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
public string MimeType { get; set; }
}
#endregion
#region LoadedSources Request/Response
/// <summary>
/// Response body for 'loadedSources' request.
/// </summary>
public class LoadedSourcesResponseBody
{
[JsonProperty("sources")]
public List<Source> Sources { get; set; } = new List<Source>();
}
/// <summary>
/// Body for 'loadedSource' event.
/// </summary>
public class LoadedSourceEventBody
{
/// <summary>
/// "new" | "changed" | "removed"
/// </summary>
[JsonProperty("reason")]
public string Reason { get; set; }
[JsonProperty("source")]
public Source Source { get; set; }
}
#endregion
#region SetBreakpoints Request/Response
/// <summary>
/// Arguments for 'setBreakpoints' request.
/// </summary>
public class SetBreakpointsArguments
{
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
[JsonProperty("breakpoints")]
public List<SourceBreakpoint> Breakpoints { get; set; } = new List<SourceBreakpoint>();
}
/// <summary>
/// Properties of a breakpoint passed to the setBreakpoints request.
/// </summary>
public class SourceBreakpoint
{
[JsonProperty("line")]
public int Line { get; set; }
[JsonProperty("condition", NullValueHandling = NullValueHandling.Ignore)]
public string Condition { get; set; }
[JsonProperty("logMessage", NullValueHandling = NullValueHandling.Ignore)]
public string LogMessage { get; set; }
}
/// <summary>
/// Response body for 'setBreakpoints' request.
/// </summary>
public class SetBreakpointsResponseBody
{
[JsonProperty("breakpoints")]
public List<Breakpoint> Breakpoints { get; set; } = new List<Breakpoint>();
}
/// <summary>
/// Information about a breakpoint created in setBreakpoints.
/// </summary>
public class Breakpoint
{
[JsonProperty("verified")]
public bool Verified { get; set; }
[JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)]
public int? Line { get; set; }
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
public Source Source { get; set; }
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
public string Message { get; set; }
}
#endregion
#region Scopes Request/Response
/// <summary>

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
@@ -22,24 +21,6 @@ namespace GitHub.Runner.Worker.Dap
Task WaitUntilReadyAsync();
Task OnStepStartingAsync(IStep step);
void OnStepCompleted(IStep step);
/// <summary>
/// Called after JobExtension.InitializeJob has returned and the initial
/// step queue + post-step stack have been populated. The debugger uses
/// these snapshots to build the synthesized job execution view served
/// via the DAP source request.
/// </summary>
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
/// <summary>
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
/// is pushed onto the post-job stack. The debugger appends the step
/// to the running execution view so the rendered YAML reflects the
/// newly-known post-step.
/// </summary>
void OnPostStepRegistered(IStep step);
Task OnJobCompletedAsync();
Task StopAsync();
}
}

View File

@@ -1,299 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
/// in the rendered YAML where that step's <c>- step:</c> key appears.
///
/// Append-only growth model: post-steps are discovered lazily during execution
/// and appended. Setup/pre/main entry line numbers are stable across appends —
/// only the synthetic Cleanup boundary (which is not tracked here) shifts.
/// </summary>
internal sealed class JobExecutionView
{
private readonly object _lock = new();
private readonly string _jobId;
private readonly List<JobExecutionViewEntry> _entries = new();
private readonly List<IStep> _stepIdentities = new();
private readonly Dictionary<IStep, int> _lineByStep =
new(ReferenceEqualityComparer.Instance);
// Map matchKey -> entry index for placeholders awaiting a future
// TryClaim. Removed when claimed.
private readonly Dictionary<string, int> _unclaimedByKey =
new(StringComparer.Ordinal);
private string _yaml;
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
public JobExecutionView(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
}
_jobId = jobId;
Render();
}
public string JobId
{
get { return _jobId; }
}
/// <summary>
/// Currently rendered YAML. Always reflects all entries appended so far,
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
/// </summary>
public string Yaml
{
get
{
lock (_lock)
{
return _yaml;
}
}
}
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
public int EntryCount
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
/// <summary>
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
/// currently appears in <see cref="Yaml"/>.
/// </summary>
public int GetLine(int entryIndex)
{
lock (_lock)
{
if (entryIndex < 0 || entryIndex >= _entries.Count)
{
throw new ArgumentOutOfRangeException(nameof(entryIndex));
}
return _entryStartLines[entryIndex];
}
}
/// <summary>
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
/// is null or has not been registered.
/// </summary>
public int? TryGetLineForStep(IStep step)
{
if (step == null)
{
return null;
}
lock (_lock)
{
if (_lineByStep.TryGetValue(step, out var line))
{
return line;
}
return null;
}
}
/// <summary>
/// Append a new entry. If <paramref name="stepIdentity"/> is non-null,
/// registers the IStep -> line mapping for later lookup. If
/// <paramref name="matchKey"/> is non-null, the entry is registered
/// as an unclaimed placeholder that a future
/// <see cref="TryClaim(string, IStep)"/> call can bind to a real
/// IStep (used by the predictive Post-step path). Re-renders the
/// YAML and updates the start-line table.
/// </summary>
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
{
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
lock (_lock)
{
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
{
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
}
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
Render();
int index = _entries.Count - 1;
if (matchKey != null)
{
_unclaimedByKey[matchKey] = index;
}
return _entryStartLines[index];
}
}
/// <summary>
/// Bind a previously-appended placeholder entry (registered via
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
/// Returns the 1-based line of the now-claimed entry on success.
/// Returns null when no unclaimed placeholder exists for
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
/// is already registered for a different entry (defensive).
/// Does not re-render: claim only updates the IStep -> line index.
/// </summary>
public int? TryClaim(string matchKey, IStep stepIdentity)
{
if (matchKey == null)
{
throw new ArgumentNullException(nameof(matchKey));
}
if (stepIdentity == null)
{
throw new ArgumentNullException(nameof(stepIdentity));
}
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return null;
}
if (_lineByStep.ContainsKey(stepIdentity))
{
// Bail rather than double-register the step.
return null;
}
_unclaimedByKey.Remove(matchKey);
_stepIdentities[index] = stepIdentity;
_lineByStep[stepIdentity] = _entryStartLines[index];
return _entryStartLines[index];
}
}
/// <summary>
/// Mark a previously-appended unclaimed placeholder as skipped. Used
/// when the predicting Main step never runs (skipped by <c>if:</c>),
/// so its predicted Post-step placeholder should not appear as a
/// step that will execute. Re-renders the view (inline comment only
/// — subsequent entry line numbers stay stable).
/// </summary>
/// <returns>
/// true if a matching unclaimed placeholder was marked; false when
/// no placeholder exists for <paramref name="matchKey"/>, or the
/// placeholder has already been claimed (claim wins).
/// </returns>
public bool TryMarkSkipped(string matchKey)
{
ArgUtil.NotNull(matchKey, nameof(matchKey));
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return false;
}
// Defensive: only mark if it's still an unclaimed placeholder.
if (_stepIdentities[index] != null)
{
return false;
}
if (_entries[index].IsSkipped)
{
// Idempotent — already marked.
return true;
}
_entries[index].IsSkipped = true;
_unclaimedByKey.Remove(matchKey);
Render();
return true;
}
}
/// <summary>
/// Bulk-append for the initial population. Equivalent to calling
/// <see cref="Append"/> once per pair, but renders only once at the end.
/// State is left unchanged if any input is invalid.
/// </summary>
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
{
ArgUtil.NotNull(items, nameof(items));
// Materialize first so we don't enumerate twice.
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
for (int i = 0; i < materialized.Count; i++)
{
if (materialized[i].entry == null)
{
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
}
}
lock (_lock)
{
// Validate no duplicates within the input or with existing identities,
// before mutating state.
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
foreach (var (_, stepIdentity) in materialized)
{
if (stepIdentity == null)
{
continue;
}
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
{
throw new InvalidOperationException("step already registered in execution view");
}
}
foreach (var (entry, stepIdentity) in materialized)
{
_entries.Add(entry);
_stepIdentities.Add(stepIdentity);
}
Render();
}
}
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
private void Render()
{
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
_yaml = result.Yaml;
_entryStartLines = result.EntryStartLines;
_lineByStep.Clear();
for (int i = 0; i < _stepIdentities.Count; i++)
{
var step = _stepIdentities[i];
if (step != null)
{
_lineByStep[step] = _entryStartLines[i];
}
}
}
}
}

View File

@@ -1,391 +0,0 @@
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
{
/// <summary>
/// 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 <see cref="JobExecutionViewRenderer"/>
/// and cannot be constructed by callers.
/// </summary>
internal enum JobExecutionPhase
{
Pre,
Main,
Post,
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>
/// Set when the corresponding step was skipped (e.g. predicted Post
/// placeholder for a Main step that never executed because its
/// <c>if:</c> evaluated false). Rendered as an inline YAML comment
/// on the entry's <c>- step:</c> line so subsequent entry line
/// numbers stay stable.
/// </summary>
public bool IsSkipped { get; internal set; }
}
/// <summary>
/// Output of <see cref="JobExecutionViewRenderer.Render"/>: the YAML
/// document plus a parallel array of 1-based line numbers, one per
/// input entry, where each entry's <c>- step:</c> key appears.
/// Synthetic Setup/Cleanup boundaries are not tracked here.
/// </summary>
internal readonly struct RenderResult
{
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines)
{
Yaml = yaml;
EntryStartLines = entryStartLines;
}
public string Yaml { get; }
public IReadOnlyList<int> EntryStartLines { get; }
}
/// <summary>
/// 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:
/// <c>setup:</c>, <c>pre:</c>, <c>main:</c>, <c>post:</c>, <c>cleanup:</c>.
/// <c>setup:</c> and <c>cleanup:</c> always render; <c>pre:</c>,
/// <c>main:</c>, <c>post:</c> only render when they contain at least
/// one entry.
/// </summary>
internal static class JobExecutionViewRenderer
{
public static RenderResult Render(string jobId, IReadOnlyList<JobExecutionViewEntry> 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<JobExecutionViewEntry> 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;
}
/// <summary>
/// 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 <see cref="Emitter"/> is created per call, so the helper
/// is safe to invoke concurrently.
/// </summary>
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');
}
}
}

View File

@@ -1,238 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Translates runner <see cref="IStep"/> instances into pure-data
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
/// execution view. Filters out runner-internal steps (e.g.
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
/// user-visible workflow steps.
/// </summary>
internal static class StepEntryTranslator
{
// Run-step internals carried on ActionStep.Inputs that are NOT
// user-authored `with:` entries.
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
{
"script",
"shell",
"working-directory",
};
/// <summary>
/// Translate an IStep into a JobExecutionViewEntry.
/// </summary>
/// <param name="step">The IStep to translate. Must not be null.</param>
/// <returns>
/// A JobExecutionViewEntry, or null if the step is not user-visible
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
/// </returns>
public static JobExecutionViewEntry TryTranslate(IStep step)
{
ArgUtil.NotNull(step, nameof(step));
if (step is JobExtensionRunner)
{
return null;
}
if (step is not IActionRunner actionRunner)
{
return null;
}
var phase = actionRunner.Stage switch
{
ActionRunStage.Pre => JobExecutionPhase.Pre,
ActionRunStage.Post => JobExecutionPhase.Post,
_ => JobExecutionPhase.Main,
};
string displayName = actionRunner.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = "run";
}
string uses = null;
string run = null;
string id = null;
string ifCond = null;
string continueOnError = null;
string timeoutMinutes = null;
string envYaml = null;
string withYaml = null;
string shell = null;
string workingDirectory = null;
var action = actionRunner.Action;
var reference = action?.Reference;
bool isScript = reference?.Type == ActionSourceType.Script;
if (reference != null && !isScript)
{
uses = FormatActionReference(reference);
}
// Only the user-visible Main entry surfaces authored params.
// Pre/Post stay minimal (step + action) — they reference the
// same Action as the Main entry, and duplicating params adds
// noise without information.
if (phase == JobExecutionPhase.Main && action != null)
{
id = FilterAuthoredId(action.ContextName);
if (!string.IsNullOrEmpty(action.Condition))
{
ifCond = action.Condition;
}
if (action.ContinueOnError != null)
{
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
}
if (action.TimeoutInMinutes != null)
{
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
}
if (action.Environment is MappingToken envMap && envMap.Count > 0)
{
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
}
else if (action.Environment != null && !(action.Environment is MappingToken))
{
// Unusual but possible: env: ${{ ... }} expression form.
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
}
if (isScript)
{
var inputs = action.Inputs as MappingToken;
if (inputs != null)
{
if (TryGetMapValue(inputs, "script", out var scriptTok) && scriptTok != null)
{
run = scriptTok.ToString();
}
if (TryGetMapValue(inputs, "shell", out var shellTok) && shellTok != null)
{
string shellText = shellTok.ToString();
if (!string.IsNullOrEmpty(shellText))
{
shell = shellText;
}
}
if (TryGetMapValue(inputs, "working-directory", out var wdTok) && wdTok != null)
{
string wdText = wdTok.ToString();
if (!string.IsNullOrEmpty(wdText))
{
workingDirectory = wdText;
}
}
}
}
else
{
// Action step: surface `with:` entries, filtering any
// run-step internal keys defensively.
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
{
var filtered = FilterMapping(withMap, RunStepInternalKeys);
if (filtered != null && filtered.Count > 0)
{
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
}
}
}
}
// Source annotation (SourcePath/SourceLine) requires a public
// seam onto TemplateToken position info — not wired yet.
return new JobExecutionViewEntry(
phase: phase,
displayName: displayName,
uses: uses,
run: run,
sourcePath: null,
sourceLine: 0,
id: id,
@if: ifCond,
continueOnError: continueOnError,
timeoutMinutes: timeoutMinutes,
envYaml: envYaml,
withYaml: withYaml,
shell: shell,
workingDirectory: workingDirectory);
}
/// <summary>
/// Auto-generated step IDs are noise in the view: filter them out.
/// The runner's convention (see ExecutionContext) is that auto-
/// generated context names start with <c>__</c>. Only user-authored
/// IDs survive the filter.
/// </summary>
internal static string FilterAuthoredId(string contextName)
{
if (string.IsNullOrWhiteSpace(contextName))
{
return null;
}
if (contextName.StartsWith("__", StringComparison.Ordinal))
{
return null;
}
return contextName;
}
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
{
foreach (var pair in map)
{
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
{
value = pair.Value;
return true;
}
}
value = null;
return false;
}
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
{
var copy = new MappingToken(source.FileId, source.Line, source.Column);
foreach (var pair in source)
{
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
{
continue;
}
copy.Add(pair);
}
return copy;
}
internal static string FormatActionReference(ActionStepDefinitionReference reference)
{
switch (reference)
{
case RepositoryPathReference repo:
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
return string.IsNullOrEmpty(repo.Ref)
? $"{repo.Name}{path}"
: $"{repo.Name}{path}@{repo.Ref}";
case ContainerRegistryReference container:
return container.Image;
default:
return reference.ToString();
}
}
}
}

View File

@@ -1,148 +0,0 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
/// can be serialized back to YAML preserving its pre-evaluation form
/// (basic <c>${{ }}</c> expressions are written through verbatim).
///
/// Used by the DAP execution view to surface user-authored step
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
/// any expression substitution.
/// </summary>
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
{
private readonly IEmitter _emitter;
public TemplateTokenYamlAdapter(IEmitter emitter)
{
ArgUtil.NotNull(emitter, nameof(emitter));
_emitter = emitter;
}
public void WriteStart()
{
_emitter.Emit(new StreamStart());
_emitter.Emit(new DocumentStart(null, null, true));
}
public void WriteEnd()
{
_emitter.Emit(new DocumentEnd(true));
_emitter.Emit(new StreamEnd());
}
public void WriteNull() =>
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
public void WriteBoolean(bool value) =>
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
public void WriteNumber(double value) =>
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
public void WriteString(string value)
{
if (value == null)
{
WriteNull();
return;
}
// Multi-line strings render as block literal so embedded
// newlines survive the YAML round trip.
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
_emitter.Emit(new Scalar(null, null, value, style, true, true));
}
public void WriteSequenceStart() =>
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
public void WriteSequenceEnd() =>
_emitter.Emit(new SequenceEnd());
public void WriteMappingStart() =>
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
public void WriteMappingEnd() =>
_emitter.Emit(new MappingEnd());
/// <summary>
/// Serialize a TemplateToken to a YAML fragment ready to embed
/// under a parent key. Each non-empty line is prefixed by
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
/// the YAML stream start/document markers are stripped, so the
/// caller controls line breaks.
/// </summary>
/// <remarks>
/// Empty mappings render as <c>{}</c> and empty sequences as
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
/// collections.
/// </remarks>
internal static string Serialize(TemplateToken token, int indentSpaces)
{
if (indentSpaces < 0)
{
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
var emitter = new Emitter(sw);
var adapter = new TemplateTokenYamlAdapter(emitter);
TemplateWriter.Write(adapter, token);
string raw = sw.ToString();
// Strip YAML document markers ("--- " prefix and "\n..." suffix).
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
}
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
raw = raw.TrimEnd('\n');
if (indentSpaces == 0)
{
return raw;
}
// Re-indent every non-empty line. Empty lines remain empty
// so YAML block-literal blank lines stay valid.
var pad = new string(' ', indentSpaces);
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
int i = 0;
while (i < raw.Length)
{
int end = raw.IndexOf('\n', i);
int lineEnd = end < 0 ? raw.Length : end;
if (lineEnd > i)
{
sb.Append(pad);
sb.Append(raw, i, lineEnd - i);
}
if (end < 0)
{
break;
}
sb.Append('\n');
i = end + 1;
}
return sb.ToString();
}
}
}

View File

@@ -338,14 +338,6 @@ namespace GitHub.Runner.Worker
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
Root.PostJobSteps.Push(step);
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
if (Global.Debugger?.Enabled == true)
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
}
public IExecutionContext CreateChild(

View File

@@ -16,7 +16,6 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
@@ -51,7 +50,6 @@ namespace GitHub.Runner.Worker
private Task _diskSpaceCheckTask = null;
private CancellationTokenSource _serviceConnectivityCheckToken = new();
private Task _serviceConnectivityCheckTask = null;
private IDapDebugger _dapDebugger;
// Download all required actions.
// Make sure all condition inputs are valid.
@@ -69,7 +67,6 @@ namespace GitHub.Runner.Worker
List<IStep> preJobSteps = new();
List<IStep> jobSteps = new();
var initSucceeded = false;
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
{
try
@@ -80,25 +77,20 @@ namespace GitHub.Runner.Worker
var setting = HostContext.GetService<IConfigurationStore>().GetSettings();
var credFile = HostContext.GetConfigFile(WellKnownConfigFile.Credentials);
var credData = File.Exists(credFile) ? IOUtil.LoadObject<CredentialData>(credFile) : null;
// self-hosted runner is the only runner type using OAuth, can be identified via clientId
if (credData != null &&
credData.Data.TryGetValue("clientId", out _))
if (File.Exists(credFile))
{
context.Output($"Runner name: '{setting.AgentName}'");
// use system variable for group name since self-hosted runners can be renamed
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
var credData = IOUtil.LoadObject<CredentialData>(credFile);
if (credData != null &&
credData.Data.TryGetValue("clientId", out var clientId))
{
context.Output($"Runner group name: '{runnerGroupName.Value}'");
// print out HostName for self-hosted runner
context.Output($"Runner name: '{setting.AgentName}'");
if (message.Variables.TryGetValue("system.runnerGroupName", out VariableValue runnerGroupName))
{
context.Output($"Runner group name: '{runnerGroupName.Value}'");
}
context.Output($"Machine name: '{Environment.MachineName}'");
}
// print out machine name for self-hosted runner
context.Output($"Machine name: '{Environment.MachineName}'");
}
// print runner info for lhr runners, skips standard runners (PoolId = 0)
else if (setting.PoolId > 0 && !string.IsNullOrEmpty(setting.PoolName) && !string.IsNullOrEmpty(setting.AgentName))
{
context.Output($"Runner name: '{setting.AgentName}'");
context.Output($"Runner group name: '{setting.PoolName}'");
}
var setupInfoFile = HostContext.GetConfigFile(WellKnownConfigFile.SetupInfo);
@@ -484,41 +476,6 @@ namespace GitHub.Runner.Worker
Trace.Info($"Start checking service connectivity in background.");
_serviceConnectivityCheckTask = CheckServiceConnectivityAsync(context, _serviceConnectivityCheckToken.Token);
// Start the DAP debugger and wait for a client connection inside
// "Set up job" so the step stays in-progress while we wait.
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled — starting inside Set up job");
context.Output("Starting debugger…");
try
{
_dapDebugger = HostContext.GetService<IDapDebugger>();
await _dapDebugger.StartAsync(jobContext);
context.Output("Waiting for debugger client to connect…");
await _dapDebugger.WaitUntilReadyAsync();
context.Output("Debugger connected.");
AddDebuggerConnectionTelemetry(jobContext, "Connected");
}
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
context.Error("Job was cancelled before debugger client connected.");
throw;
}
catch (Exception ex)
{
Trace.Error($"DAP debugger failed: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.GetType().Name}");
context.Error("The debugger failed to start or no debugger client connected in time.");
throw;
}
}
initSucceeded = true;
return steps;
}
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
@@ -539,36 +496,12 @@ namespace GitHub.Runner.Worker
}
finally
{
// If InitializeJob failed after the debugger was started,
// tear down the transport here since FinalizeJob won't run.
if (!initSucceeded && _dapDebugger != null)
{
try
{
await _dapDebugger.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger cleanup during failed init: {ex.Message}");
}
_dapDebugger = null;
}
context.Debug("Finishing: Set up job");
context.Complete();
}
}
}
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
{
jobContext.Global.JobTelemetry.Add(new JobTelemetry
{
Type = JobTelemetryType.General,
Message = $"DebuggerConnectionResult: {result}"
});
}
private string GetWorkflowReference(IDictionary<string, VariableValue> variables)
{
var reference = "";
@@ -844,34 +777,6 @@ namespace GitHub.Runner.Worker
}
finally
{
// Pause for debugger inspection, then tear down the DAP session.
// OnJobCompletedAsync pauses first, then sends terminated/exited
// events and stops the transport.
if (_dapDebugger != null)
{
context.Output("Job completed — pausing for debugger inspection. Press continue to finish.");
try
{
await _dapDebugger.OnJobCompletedAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger completion error: {ex.Message}");
}
finally
{
try
{
await _dapDebugger.StopAsync();
}
catch (Exception ex)
{
Trace.Warning($"DAP debugger stop error: {ex.Message}");
}
}
_dapDebugger = null;
}
context.Debug("Finishing: Complete job");
context.Complete();
}

View File

@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using Sdk.RSWebApi.Contracts;
@@ -28,6 +29,7 @@ namespace GitHub.Runner.Worker
public sealed class JobRunner : RunnerService, IJobRunner
{
private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult";
private IJobServerQueue _jobServerQueue;
private RunnerSettings _runnerSettings;
private ITempDirectoryManager _tempDirectoryManager;
@@ -112,6 +114,7 @@ namespace GitHub.Runner.Worker
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
IDapDebugger dapDebugger = null;
try
{
// Create the job execution context.
@@ -178,6 +181,25 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled for this job run");
try
{
dapDebugger = HostContext.GetService<IDapDebugger>();
await dapDebugger.StartAsync(jobContext);
}
catch (Exception ex)
{
Trace.Error($"Failed to start DAP debugger: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
jobContext.Error("Failed to start debugger.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Get the job extension.
Trace.Info("Getting job extension.");
@@ -220,6 +242,33 @@ namespace GitHub.Runner.Worker
await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000));
}
// Wait for DAP debugger client connection and handshake after "Set up job"
// so the job page shows the setup step before we block on the debugger
if (dapDebugger != null)
{
try
{
await dapDebugger.WaitUntilReadyAsync();
AddDebuggerConnectionTelemetry(jobContext, "Connected");
}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
AddDebuggerConnectionTelemetry(jobContext, "Canceled");
jobContext.Error("Job was cancelled before debugger client connected.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled);
}
catch (Exception ex)
{
Trace.Error($"DAP debugger failed to become ready: {ex.Message}");
AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}");
// If debugging was requested but the debugger is not available, fail the job
jobContext.Error("The debugger failed to start or no debugger client connected in time.");
return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed);
}
}
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
@@ -230,24 +279,6 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
try
{
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
Trace.Error(ex);
}
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)
@@ -278,6 +309,11 @@ namespace GitHub.Runner.Worker
runnerShutdownRegistration = null;
}
if (dapDebugger != null)
{
await dapDebugger.OnJobCompletedAsync();
}
await ShutdownQueue(throwOnFailure: false);
}
}
@@ -459,6 +495,15 @@ namespace GitHub.Runner.Worker
throw new AggregateException(exceptions);
}
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
{
jobContext.Global.JobTelemetry.Add(new JobTelemetry
{
Type = JobTelemetryType.General,
Message = $"{DebuggerConnectionTelemetryPrefix}: {result}"
});
}
private void MaskTelemetrySecrets(List<JobTelemetry> jobTelemetry)
{
foreach (var telemetryItem in jobTelemetry)

View File

@@ -19,11 +19,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.6" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.43" />
</ItemGroup>
<ItemGroup>

View File

@@ -219,18 +219,12 @@ namespace GitHub.Runner.Worker
// Condition is false
Trace.Info("Skipping step due to condition evaluation.");
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
// Notify the DAP debugger so any predicted Post-step
// placeholder for this Main step can be marked as
// skipped — otherwise the rendered view leaves a
// stale "Post X" entry for a step that never ran.
dapDebugger?.OnStepCompleted(step);
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
CompleteStep(step, TaskResult.Failed);
dapDebugger?.OnStepCompleted(step);
}
else
{

View File

@@ -8,7 +8,6 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
public const String Email = nameof(Email);
public const String IPv4Address = nameof(IPv4Address);
public const String SHA1 = nameof(SHA1);
public const String CommitHash = nameof(CommitHash);
public const String Url = nameof(Url);
/// <summary>
@@ -25,8 +24,7 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
case IPv4Address:
return s_validIPv4Address;
case SHA1:
case CommitHash:
return s_validCommitHash;
return s_validSha1;
case Url:
return s_validUrl;
default:
@@ -48,9 +46,9 @@ namespace GitHub.DistributedTask.Pipelines.Expressions
)
);
// 40 or 64 hex characters (SHA-1 or SHA-256 commit hash)
private static readonly Lazy<Regex> s_validCommitHash = new Lazy<Regex>(() => new Regex(
@"\b(?:[0-9a-f]{40}|[0-9a-f]{64})\b",
// 40 hex characters
private static readonly Lazy<Regex> s_validSha1 = new Lazy<Regex>(() => new Regex(
@"\b[0-9a-f]{40}\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexUtility.GetRegexTimeOut()
)
);

View File

@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.7" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.6" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
</ItemGroup>
<ItemGroup>

View File

@@ -24,10 +24,7 @@ namespace GitHub.Runner.Common.Tests
"osx-arm64"
};
Assert.True(
BuildConstants.Source.CommitHash.Length == 40 || BuildConstants.Source.CommitHash.Length == 64,
"CommitHash should be a 40-char SHA-1 or 64-char SHA-256 hex string");
Assert.Matches("^[0-9a-f]+$", BuildConstants.Source.CommitHash);
Assert.Equal(40, BuildConstants.Source.CommitHash.Length);
Assert.True(validPackageNames.Contains(BuildConstants.RunnerPackage.PackageName), $"PackageName should be one of the following '{string.Join(", ", validPackageNames)}', current PackageName is '{BuildConstants.RunnerPackage.PackageName}'");
}
}

View File

@@ -29,10 +29,18 @@ namespace GitHub.Runner.Common.Tests.Listener
private Mock<ICredentialManager> _credentialManager;
private Mock<IActionsRunServer> _actionsRunServer;
private Mock<IRunServer> _runServer;
private readonly string _returnJobResultForHosted;
// ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED is set on GitHub-hosted runners and
// silently forces runOnce=true, which breaks tests that expect non-ephemeral behaviour
// or assert a specific return code. Clear it for the entire test class and restore on
// Dispose so individual tests remain environment-independent.
private readonly string _savedJobResultEnvVar;
public RunnerL0()
{
_savedJobResultEnvVar = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED");
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", null);
_configurationManager = new Mock<IConfigurationManager>();
_jobNotification = new Mock<IJobNotification>();
_messageListener = new Mock<IMessageListener>();
@@ -46,14 +54,11 @@ namespace GitHub.Runner.Common.Tests.Listener
_credentialManager = new Mock<ICredentialManager>();
_actionsRunServer = new Mock<IActionsRunServer>();
_runServer = new Mock<IRunServer>();
_returnJobResultForHosted = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED");
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", null);
}
public void Dispose()
{
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", _returnJobResultForHosted);
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED", _savedJobResultEnvVar);
}
private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName)
@@ -116,6 +121,7 @@ namespace GitHub.Runner.Common.Tests.Listener
{
signalWorkerComplete.Release();
await Task.Delay(2000, hc.RunnerShutdownToken);
return null;
}
return messages.Dequeue();

View File

@@ -1,100 +0,0 @@
using GitHub.DistributedTask.Pipelines.Expressions;
using Xunit;
namespace GitHub.Runner.Common.Tests.Sdk
{
public sealed class WellKnownRegularExpressionsL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void SHA1_Key_Returns_CommitHash_Regex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
Assert.NotNull(regex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CommitHash_Key_Returns_CommitHash_Regex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.NotNull(regex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void SHA1_And_CommitHash_Return_Same_Regex()
{
var sha1Regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.SHA1);
var commitHashRegex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Same(sha1Regex, commitHashRegex);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_40_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Matches(regex.Value, new string('a', 40));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_64_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.Matches(regex.Value, new string('a', 64));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Does_Not_Match_63_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.DoesNotMatch(regex.Value, new string('a', 63));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Does_Not_Match_65_Char_Hex()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
Assert.DoesNotMatch(regex.Value, new string('a', 65));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Matches_Mixed_Case_64_Char()
{
var regex = WellKnownRegularExpressions.GetRegex(WellKnownRegularExpressions.CommitHash);
var value = new string('A', 32) + new string('b', 32);
Assert.Matches(regex.Value, value);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void Unknown_Key_Returns_Null()
{
var regex = WellKnownRegularExpressions.GetRegex("UnknownType");
Assert.Null(regex);
}
}
}

View File

@@ -25,7 +25,6 @@ namespace GitHub.Runner.Common.Tests.Worker
public sealed class ActionManagerL0
{
private const string TestDataFolderName = "TestData";
private const string Sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private CancellationTokenSource _ecTokenSource;
private Mock<IConfigurationStore> _configurationStore;
private Mock<IDockerCommandManager> _dockerManager;
@@ -335,7 +334,7 @@ runs:
await File.WriteAllTextAsync(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact", "action.yml"), Content);
#if OS_WINDOWS
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.zip"), CompressionLevel.Fastest, true);
ZipFile.CreateFromDirectory(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"), Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.zip"), CompressionLevel.Fastest, true);
#else
string tar = WhichUtil.Which("tar", require: true, trace: _hc.GetTrace());
@@ -361,7 +360,7 @@ runs:
string cwd = Path.GetDirectoryName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
string inputDirectory = Path.GetFileName(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions-download-artifact"));
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", $"{Sha256}.tar.gz");
string archiveFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "action_cache", "actions_download-artifact", "master-sha.tar.gz");
int exitCode = await processInvoker.ExecuteAsync(_hc.GetDirectory(WellKnownDirectory.Bin), tar, $"-czf \"{archiveFile}\" -C \"{cwd}\" \"{inputDirectory}\"", null, CancellationToken.None);
if (exitCode != 0)
{
@@ -369,8 +368,6 @@ runs:
}
}
#endif
MockResolvedSha("actions/download-artifact", "master", Sha256);
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
@@ -519,10 +516,9 @@ runs:
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
Directory.CreateDirectory(actionsArchive);
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256));
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content"));
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", Sha256, "content", "action.yml"), Content);
MockResolvedSha("actions/checkout", "master", Sha256);
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
//Act
@@ -3153,51 +3149,6 @@ runs:
#endif
}
private void MockResolvedSha(string nameWithOwner, string reference, string resolvedSha)
{
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = resolvedSha,
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
_launchServer.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.Is<ActionReferenceList>(actions => actions.Actions.Any(action => action.NameWithOwner == nameWithOwner && action.Ref == reference)), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = resolvedSha,
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
}
private void Setup([CallerMemberName] string name = "", bool enableComposite = true)
{
_ecTokenSource?.Dispose();

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
@@ -744,32 +744,14 @@ namespace GitHub.Runner.Common.Tests.Worker
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — OnJobCompletedAsync pauses when stepping,
// so run it in the background and send continue to unblock.
var completedTask = _debugger.OnJobCompletedAsync();
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
// Read the stopped event from the pause
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Send continue to unblock the pause
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "continue"
});
await completedTask;
// Read remaining messages — continue response + continued event + terminated + exited
var allMessages = new System.Text.StringBuilder();
for (int i = 0; i < 4; i++)
{
allMessages.Append(await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)));
}
var combined = allMessages.ToString();
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
}
@@ -827,614 +809,5 @@ namespace GitHub.Runner.Common.Tests.Worker
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Start OnJobCompletedAsync — it will pause because _pauseOnNextStep is true
var completedTask = _debugger.OnJobCompletedAsync();
// Read the stopped event
var stoppedMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedMsg);
// Cancel the job while waiting — should unblock the pause
cts.Cancel();
// OnJobCompletedAsync should complete without hanging
var finished = await Task.WhenAny(completedTask, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Equal(completedTask, finished);
}
}
// ---------------------------------------------------------------------
// Phase 2c: synthesized execution view as DAP source.
// ---------------------------------------------------------------------
private static Mock<GitHub.Runner.Worker.IActionRunner> NewActionRunner(
GitHub.Runner.Worker.ActionRunStage stage,
string displayName,
string actionName = "actions/checkout",
string actionRef = "v4")
{
var mock = new Mock<GitHub.Runner.Worker.IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep
{
Reference = new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
Name = actionName,
Ref = actionRef,
},
});
return mock;
}
private async Task DriveDebuggerToReadyAsync(int port)
{
var waitTask = _debugger.WaitUntilReadyAsync();
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone",
});
await waitTask;
// Hold the client alive via GC root in caller scope through field.
_liveDriveClient = client;
}
private TcpClient _liveDriveClient;
private static Request MakeRequest(string command, object args = null)
{
return new Request
{
Seq = 1,
Type = "request",
Command = command,
Arguments = args == null ? null : Newtonsoft.Json.Linq.JObject.FromObject(args),
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSource_ReturnsExecutionViewYaml()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
Assert.True(response.Success);
var body = Assert.IsType<SourceResponseBody>(response.Body);
Assert.Equal(_debugger.ExecutionView.Yaml, body.Content);
Assert.Null(body.MimeType);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSource_UnknownReference_Fails()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 999 }));
Assert.False(response.Success);
Assert.Contains("Unknown source reference", response.Message);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSource_NoView_Fails()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
// No OnJobStepsInitializedAsync call — view is null.
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
Assert.False(response.Success);
Assert.Contains("Execution view not yet available", response.Message);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSource_OmitsMimeType()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
Assert.True(response.Success);
var body = Assert.IsType<SourceResponseBody>(response.Body);
// MimeType is intentionally omitted so DAP clients fall through
// to path-extension detection (`.yml`) — the most consistently
// honored mechanism across VS Code, nvim-dap, and JetBrains.
Assert.Null(body.MimeType);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSource_MasksSecrets()
{
const string Secret = "supersecret-shhh-value";
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
// Embed the secret in a step display name so it ends up in the rendered YAML.
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, $"Run {Secret} step").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
// Sanity-check the raw view actually contains the secret;
// otherwise the masking assertion below would pass vacuously.
Assert.Contains(Secret, _debugger.ExecutionView.Yaml);
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
Assert.True(response.Success);
var body = Assert.IsType<SourceResponseBody>(response.Body);
Assert.DoesNotContain(Secret, body.Content);
Assert.Contains("***", body.Content);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleStackTrace_MasksSecretsInSourcePath()
{
const string Secret = "secret-job-name-xyz";
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// Job name (== GitHub context "job") embeds the secret.
var jobContext = CreateJobContextWithTunnel(cts.Token, port, Secret);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
_ = _debugger.OnStepStartingAsync(step, isFirstStep: false);
await Task.Delay(50);
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
Assert.NotEmpty(body.StackFrames);
foreach (var frame in body.StackFrames)
{
if (frame.Source != null)
{
Assert.DoesNotContain(Secret, frame.Source.Path ?? string.Empty);
Assert.DoesNotContain(Secret, frame.Source.Name ?? string.Empty);
Assert.Contains("***", frame.Source.Path ?? string.Empty);
}
Assert.DoesNotContain(Secret, frame.Name ?? string.Empty);
}
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleStackTrace_TwoFramesWhenStepping()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step One").Object;
var step2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step Two", "actions/setup-node", "v3").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step1, step2 }, Array.Empty<IStep>());
// Fire-and-forget: first step pauses, but we just want _currentStep set.
_ = _debugger.OnStepStartingAsync(step1, isFirstStep: false);
await Task.Delay(50);
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
Assert.Equal(2, body.StackFrames.Count);
var frame0 = body.StackFrames[0];
Assert.Equal(1, frame0.Source.SourceReference);
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(step1), frame0.Line);
Assert.Equal("normal", frame0.PresentationHint);
var frame1 = body.StackFrames[1];
Assert.Equal(1, frame1.Line);
Assert.Equal("subtle", frame1.PresentationHint);
Assert.Equal(1, frame1.Source.SourceReference);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleStackTrace_OneFrameWhenViewMissing()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Lonely").Object;
// No view built — but pause the step so _currentStep is set.
_ = _debugger.OnStepStartingAsync(step, isFirstStep: false);
await Task.Delay(50);
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
Assert.Single(body.StackFrames);
Assert.Null(body.StackFrames[0].Source);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleStackTrace_NoFramesWhenIdle()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
Assert.Empty(body.StackFrames);
Assert.Equal(0, body.TotalFrames);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleLoadedSources_ReturnsExecutionView()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources"));
Assert.True(response.Success);
var body = Assert.IsType<LoadedSourcesResponseBody>(response.Body);
Assert.Single(body.Sources);
Assert.Equal("execution.yml", body.Sources[0].Name);
Assert.Equal("ci-job/execution.yml", body.Sources[0].Path);
Assert.Equal(1, body.Sources[0].SourceReference);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleLoadedSources_NoView_ReturnsEmpty()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources"));
Assert.True(response.Success);
var body = Assert.IsType<LoadedSourcesResponseBody>(response.Body);
Assert.Empty(body.Sources);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task HandleSetBreakpoints_ReturnsUnverifiedPlaceholders()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var args = new SetBreakpointsArguments
{
Source = new Source { SourceReference = 1 },
Breakpoints = new System.Collections.Generic.List<SourceBreakpoint>
{
new SourceBreakpoint { Line = 5 },
new SourceBreakpoint { Line = 10 },
new SourceBreakpoint { Line = 15 },
},
};
var response = _debugger.HandleSetBreakpoints(MakeRequest("setBreakpoints", args));
Assert.True(response.Success);
var body = Assert.IsType<SetBreakpointsResponseBody>(response.Body);
Assert.Equal(3, body.Breakpoints.Count);
Assert.All(body.Breakpoints, bp => Assert.False(bp.Verified));
Assert.Equal(new[] { 5, 10, 15 }, body.Breakpoints.ConvertAll(b => b.Line ?? -1));
Assert.All(body.Breakpoints, bp => Assert.False(string.IsNullOrEmpty(bp.Message)));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_EmitsLoadedSourceNewEvent()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
try
{
var stream = client.GetStream();
await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" });
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // configurationDone response
await _debugger.WaitUntilReadyAsync();
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"loadedSource\"", msg);
Assert.Contains("\"reason\":\"new\"", msg);
Assert.Contains("\"sourceReference\":1", msg);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_EmitsLoadedSourceChangedEvent()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
try
{
var stream = client.GetStream();
await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" });
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await _debugger.WaitUntilReadyAsync();
var main = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { main }, Array.Empty<IStep>());
// Drain the "new" loadedSource event.
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var post = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Post, "Post Run", "actions/cache", "v3").Object;
_debugger.OnPostStepRegistered(post);
var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"loadedSource\"", msg);
Assert.Contains("\"reason\":\"changed\"", msg);
Assert.Contains("\"sourceReference\":1", msg);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTrace_LineUpdatesAsStepsAdvance()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveDebuggerToReadyAsync(port);
var s1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
var s2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2 }, Array.Empty<IStep>());
_ = _debugger.OnStepStartingAsync(s1, isFirstStep: true);
await Task.Delay(50);
var first = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var firstBody = Assert.IsType<StackTraceResponseBody>(first.Body);
int firstLine = firstBody.StackFrames[0].Line;
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s1), firstLine);
_ = _debugger.OnStepStartingAsync(s2, isFirstStep: false);
await Task.Delay(50);
var second = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
var secondBody = Assert.IsType<StackTraceResponseBody>(second.Body);
int secondLine = secondBody.StackFrames[0].Line;
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s2), secondLine);
Assert.NotEqual(firstLine, secondLine);
}
finally
{
await _debugger.StopAsync();
}
}
}
}
}

View File

@@ -7,7 +7,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -406,7 +405,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -505,7 +503,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -547,75 +544,6 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message with EnableDebugger left at the default (false).
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
// Register a strict mock IDapDebugger. If the production code calls
// ANY method on it, the test fails — proving the containment guard
// short-circuited before HostContext.GetService<IDapDebugger>().
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "post";
// Sanity: ensure the production code path actually believes the debugger is disabled.
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
// Act.
action.RegisterPostJobStep(postRunner);
// Assert: the debugger was never consulted on the non-debug path.
dapMock.VerifyNoOtherCalls();
Assert.Equal(1, jobContext.PostJobSteps.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -1,467 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewL0
{
private static JobExecutionViewEntry MainEntry(string name)
{
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
}
private static IStep NewStep(string displayName = "step")
{
var mock = new Mock<IStep>();
mock.Setup(s => s.DisplayName).Returns(displayName);
return mock.Object;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Constructor_RendersEmptyView()
{
var view = new JobExecutionView("my-job");
Assert.Equal(0, view.EntryCount);
Assert.Contains("# Job: my-job", view.Yaml);
Assert.Contains("- step: Setup job", view.Yaml);
Assert.Contains("- step: Complete job", view.Yaml);
// Only the two synthetic boundaries appear.
int stepCount = view.Yaml.Split("- step: ").Length - 1;
Assert.Equal(2, stepCount);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_ThrowsOnInvalidJobId(string jobId)
{
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_IncrementsEntryCount()
{
var view = new JobExecutionView("j");
int line0 = view.Append(MainEntry("a"));
int line1 = view.Append(MainEntry("b"));
int line2 = view.Append(MainEntry("c"));
Assert.Equal(3, view.EntryCount);
Assert.True(line0 < line1);
Assert.True(line1 < line2);
Assert.Equal(line0, view.GetLine(0));
Assert.Equal(line1, view.GetLine(1));
Assert.Equal(line2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_PreservesPriorEntryLines()
{
var view = new JobExecutionView("j");
int l0 = view.Append(MainEntry("a"));
int l1 = view.Append(MainEntry("b"));
int l2 = view.Append(MainEntry("c"));
view.Append(MainEntry("d"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
view.Append(MainEntry("e"));
Assert.Equal(l0, view.GetLine(0));
Assert.Equal(l1, view.GetLine(1));
Assert.Equal(l2, view.GetLine(2));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_RegistersStepIdentity()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullStepIdentity_StillAppends()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null);
Assert.Equal(1, view.EntryCount);
Assert.Null(view.TryGetLineForStep(null));
Assert.Contains("- step: a", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateStepIdentity_Throws()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
// State preserved: only the first entry is present.
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_NullEntry_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.Append(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_AppendsAllAndRendersOnce()
{
var view = new JobExecutionView("j");
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
var items = steps
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
.ToList();
view.AppendRange(items);
Assert.Equal(5, view.EntryCount);
for (int i = 0; i < 5; i++)
{
int line = view.GetLine(i);
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsDuplicateInInput()
{
var view = new JobExecutionView("j");
var dup = NewStep();
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("a"), dup),
(MainEntry("b"), dup),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(0, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_RejectsOverlapWithExisting()
{
var view = new JobExecutionView("j");
var step = NewStep();
view.Append(MainEntry("a"), step);
var items = new List<(JobExecutionViewEntry, IStep)>
{
(MainEntry("b"), step),
};
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
Assert.Equal(1, view.EntryCount);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AppendRange_NullItems_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_NullStep_ReturnsNull()
{
var view = new JobExecutionView("j");
Assert.Null(view.TryGetLineForStep(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryGetLineForStep_UnknownStep_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
Assert.Null(view.TryGetLineForStep(step));
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(-1)]
[InlineData(2)]
public void GetLine_OutOfRange_Throws(int index)
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"));
view.Append(MainEntry("b"));
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_UpdatesAfterAppend()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("first"));
string before = view.Yaml;
Assert.Contains("- step: first", before);
view.Append(MainEntry("second"));
string after = view.Yaml;
Assert.Contains("- step: first", after);
Assert.Contains("- step: second", after);
Assert.NotEqual(before, after);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Yaml_AlwaysEndsWithCleanupBoundary()
{
var view = new JobExecutionView("j");
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("a"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
view.Append(MainEntry("b"));
view.Append(MainEntry("c"));
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_WithMatchKey_TracksUnclaimed()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep("real");
int? claimed = view.TryClaim("k1", step);
Assert.Equal(line, claimed);
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_UnknownKey_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Null(view.TryClaim("nope", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AlreadyClaimed_ReturnsNull()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
var first = NewStep("first");
Assert.NotNull(view.TryClaim("k1", first));
var second = NewStep("second");
Assert.Null(view.TryClaim("k1", second));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
{
var view = new JobExecutionView("j");
var step = NewStep();
// Step is registered for the first entry.
view.Append(MainEntry("a"), step);
// A placeholder is registered for the second entry.
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
// Trying to claim the placeholder with the already-registered
// step must return null (defensive — would otherwise double-bind).
Assert.Null(view.TryClaim("k1", step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_DuplicateMatchKey_Throws()
{
var view = new JobExecutionView("j");
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
Assert.Throws<InvalidOperationException>(
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Append_MatchKeyNull_BehavesLikeOldOverload()
{
var view = new JobExecutionView("j");
var step = NewStep();
int line = view.Append(MainEntry("a"), step);
Assert.Equal(line, view.GetLine(0));
Assert.Equal(line, view.TryGetLineForStep(step));
// TryClaim with any key must return null since no matchKey was registered.
Assert.Null(view.TryClaim("anything", NewStep()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
{
var view = new JobExecutionView("j");
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
var step = NewStep();
Assert.Equal(line, view.TryClaim("k1", step));
Assert.Equal(line, view.TryGetLineForStep(step));
// And a later Append doesn't lose the claim (Render rebuilds
// the IStep -> line map from the persisted identities).
view.Append(MainEntry("b"));
Assert.Equal(line, view.TryGetLineForStep(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryClaim_NullArgs_Throws()
{
var view = new JobExecutionView("j");
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ConcurrentAppends_DontCorruptState()
{
var view = new JobExecutionView("j");
const int N = 50;
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
var returnedLines = new ConcurrentBag<int>();
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
{
int line = view.Append(MainEntry("e" + i), steps[i]);
returnedLines.Add(line);
})).ToArray();
await Task.WhenAll(tasks);
Assert.Equal(N, view.EntryCount);
Assert.Equal(N, returnedLines.Distinct().Count());
// Every step identity resolves to some line in [0, N).
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
Assert.Equal(N, entryLines.Count);
foreach (var step in steps)
{
int? line = view.TryGetLineForStep(step);
Assert.NotNull(line);
Assert.Contains(line.Value, entryLines);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_MarksUnclaimedPlaceholder()
{
var view = new JobExecutionView("j");
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
Assert.True(view.TryMarkSkipped("k1"));
Assert.True(postEntry.IsSkipped);
Assert.Contains("(skipped — main step did not execute)", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_ReturnsFalseForUnknownKey()
{
var view = new JobExecutionView("j");
Assert.False(view.TryMarkSkipped("nope"));
Assert.DoesNotContain("(skipped", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_ReturnsFalseForClaimedPlaceholder()
{
var view = new JobExecutionView("j");
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
var step = NewStep("real-post");
Assert.NotNull(view.TryClaim("k1", step));
// Already claimed — must not mark as skipped.
Assert.False(view.TryMarkSkipped("k1"));
Assert.False(postEntry.IsSkipped);
}
}
}

View File

@@ -1,703 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class JobExecutionViewLifecycleL0
{
private DapDebugger _debugger;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_debugger.Initialize(hc);
_debugger.SkipTunnelRelay = true;
_debugger.SkipWebSocketBridge = true;
return hc;
}
private static ushort GetFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = "ci-job")
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
return jobContext;
}
private static async Task DriveToReadyAsync(DapDebugger debugger, int port)
{
var waitTask = debugger.WaitUntilReadyAsync();
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port);
var stream = client.GetStream();
var request = new Request { Seq = 1, Type = "request", Command = "configurationDone" };
var json = JsonConvert.SerializeObject(request);
var body = Encoding.UTF8.GetBytes(json);
var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
await stream.WriteAsync(header, 0, header.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
await waitTask;
// Keep client alive by holding a reference via GC root in caller scope.
// We deliberately don't dispose here; tests dispose the context.
_ = client;
}
private static Mock<IActionRunner> NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default)
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new RepositoryPathReference { Name = actionName, Ref = actionRef },
});
return mock;
}
private static Mock<IActionRunner> NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
{
// RepositoryType = "self" — the predictor must skip these.
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new RepositoryPathReference
{
RepositoryType = GitHub.DistributedTask.Pipelines.PipelineConstants.SelfAlias,
Path = "./.github/actions/local",
},
});
return mock;
}
private static Mock<IActionRunner> NewScriptActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
{
// ScriptReference — a `run:` step. Not a RepositoryPathReference,
// so the predictor's pattern match falls through.
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(new ActionStep
{
Id = actionId,
Reference = new ScriptReference(),
});
return mock;
}
// IActionManager mock that returns specific Definitions per action by
// matching on the action's reference Name. Actions whose name is not
// in the map get a Definition with HasPost = false.
private static Mock<IActionManager> NewActionManagerWithPost(params string[] actionNamesWithPost)
{
var withPost = new HashSet<string>(actionNamesWithPost, StringComparer.Ordinal);
var mock = new Mock<IActionManager>();
mock.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), It.IsAny<ActionStep>()))
.Returns((IExecutionContext _, ActionStep step) =>
{
var name = (step.Reference as RepositoryPathReference)?.Name ?? "";
return new Definition
{
Data = new ActionDefinitionData
{
Execution = withPost.Contains(name)
? new NodeJSActionExecutionData { Post = "post.js" }
: new NodeJSActionExecutionData(),
},
};
});
return mock;
}
private static IStep NewJobExtensionRunner(string displayName)
{
return new JobExtensionRunner(
runAsync: (_, __) => Task.CompletedTask,
condition: null,
displayName: displayName,
data: null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_NotActive_NoOps()
{
using (CreateTestContext())
{
var step = NewActionRunner(ActionRunStage.Main, "Run").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
Assert.Null(_debugger.ExecutionView);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_NotActive_NoOps()
{
using (CreateTestContext())
{
var step = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(step); // must not throw
Assert.Null(_debugger.ExecutionView);
await Task.CompletedTask;
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_Active_BuildsView()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
var main2 = NewActionRunner(ActionRunStage.Main, "Run actions/setup-node@v3", "actions/setup-node", "v3").Object;
var jobExt = NewJobExtensionRunner("Set up job");
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/checkout@v4").Object;
await _debugger.OnJobStepsInitializedAsync(
new IStep[] { main1, jobExt, main2 },
new IStep[] { post1 });
var view = _debugger.ExecutionView;
Assert.NotNull(view);
Assert.Equal(3, view.EntryCount); // jobExt filtered out
Assert.Contains("Run actions/checkout@v4", view.Yaml);
Assert.Contains("Run actions/setup-node@v3", view.Yaml);
Assert.Contains("Post Run actions/checkout@v4", view.Yaml);
Assert.NotNull(view.TryGetLineForStep(main1));
Assert.NotNull(view.TryGetLineForStep(main2));
Assert.NotNull(view.TryGetLineForStep(post1));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PreservesQueueOrder()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var s1 = NewActionRunner(ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
var s2 = NewActionRunner(ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
var s3 = NewActionRunner(ActionRunStage.Main, "Step 3", "e/f", "v3").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2, s3 }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.Equal(3, view.EntryCount);
var l1 = view.TryGetLineForStep(s1);
var l2 = view.TryGetLineForStep(s2);
var l3 = view.TryGetLineForStep(s3);
Assert.NotNull(l1);
Assert.NotNull(l2);
Assert.NotNull(l3);
Assert.True(l1 < l2);
Assert.True(l2 < l3);
Assert.Equal(view.GetLine(0), l1);
Assert.Equal(view.GetLine(1), l2);
Assert.Equal(view.GetLine(2), l3);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_AppendsToView()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object;
await _debugger.OnJobStepsInitializedAsync(new[] { main1 }, Array.Empty<IStep>());
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/cache@v3", "actions/cache", "v3").Object;
_debugger.OnPostStepRegistered(post1);
var view = _debugger.ExecutionView;
Assert.Equal(2, view.EntryCount);
Assert.Contains("Post Run actions/cache@v3", view.Yaml);
Assert.NotNull(view.TryGetLineForStep(post1));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_BeforeViewBuilt_NoOps()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(post); // must not throw
Assert.Null(_debugger.ExecutionView);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_DuplicateStep_DoesNotThrow()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object;
_debugger.OnPostStepRegistered(post);
_debugger.OnPostStepRegistered(post); // duplicate, must be silently ignored
Assert.Equal(1, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_FilteredStep_NoOps()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
await _debugger.OnJobStepsInitializedAsync(Array.Empty<IStep>(), Array.Empty<IStep>());
var before = _debugger.ExecutionView.EntryCount;
_debugger.OnPostStepRegistered(NewJobExtensionRunner("Cleanup"));
Assert.Equal(before, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
// ---- Predictive Post-step synthesis ----
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PredictsPostForActionsWithHasPost()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var withPost = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: Guid.NewGuid()).Object;
var noPost = NewActionRunner(ActionRunStage.Main, "Run actions/no-post@v1", "actions/no-post", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { withPost, noPost }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.NotNull(view);
// 2 main entries + 1 predicted post placeholder.
Assert.Equal(3, view.EntryCount);
Assert.Contains("post:\n", view.Yaml);
Assert.Contains("Post Run actions/has-post@v1", view.Yaml);
Assert.DoesNotContain("Post Run actions/no-post@v1", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_PostPredictionsInReverseOrder()
{
using (var hc = CreateTestContext())
{
// Both actions have post — predictions must render in
// reverse declaration order to mirror the runner's LIFO
// post-execution order.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/a", "actions/b").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var aMain = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
var bMain = NewActionRunner(ActionRunStage.Main, "Run actions/b@v1", "actions/b", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { aMain, bMain }, Array.Empty<IStep>());
string yaml = _debugger.ExecutionView.Yaml;
int idxPostB = yaml.IndexOf("Post Run actions/b@v1", StringComparison.Ordinal);
int idxPostA = yaml.IndexOf("Post Run actions/a@v1", StringComparison.Ordinal);
Assert.True(idxPostB > 0 && idxPostA > 0, "both post placeholders expected");
// Reverse declaration order: Post B appears BEFORE Post A.
Assert.True(idxPostB < idxPostA, $"expected Post B before Post A (b={idxPostB} a={idxPostA})");
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_SkipsScriptSteps()
{
using (var hc = CreateTestContext())
{
// Even if the action manager would say HasPost, the predictor
// must skip script run-steps because their reference is not
// a RepositoryPathReference.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var script = NewScriptActionRunner(ActionRunStage.Main, "Run script", Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { script }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.NotNull(view);
Assert.DoesNotContain("post:\n", view.Yaml);
Assert.DoesNotContain("Post ", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnJobStepsInitialized_SkipsSelfActions()
{
using (var hc = CreateTestContext())
{
// Self-action: ActionRunner.cs:106 guards against creating a
// Post for self-repository references. The predictor mirrors
// that, regardless of what the manifest reports.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("anything").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var selfRunner = NewSelfActionRunner(ActionRunStage.Main, "Run ./local-action", Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { selfRunner }, Array.Empty<IStep>());
Assert.DoesNotContain("post:\n", _debugger.ExecutionView.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_ClaimsExistingPlaceholder()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var actionId = Guid.NewGuid();
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
int before = view.EntryCount;
Assert.Equal(2, before); // main + predicted post placeholder
// The real Post IActionRunner shares the same Action.Id
// as the Main runner (ActionRunner.cs:131).
var postRunner = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
_debugger.OnPostStepRegistered(postRunner);
// No new entry: the placeholder was claimed.
Assert.Equal(before, view.EntryCount);
Assert.NotNull(view.TryGetLineForStep(postRunner));
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_UnpredictedFallsBackToAppend()
{
using (var hc = CreateTestContext())
{
// Manager returns no HasPost — no predictions made.
hc.SetSingleton<IActionManager>(NewActionManagerWithPost(/* nothing */).Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
int before = view.EntryCount;
Assert.Equal(1, before); // just main, no predicted post
var unpredictedPost = NewActionRunner(ActionRunStage.Post, "Post Surprise", "actions/surprise", "v1", actionId: Guid.NewGuid()).Object;
_debugger.OnPostStepRegistered(unpredictedPost);
// Falls back to Append.
Assert.Equal(before + 1, view.EntryCount);
Assert.NotNull(view.TryGetLineForStep(unpredictedPost));
Assert.Contains("Post Surprise", view.Yaml);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_DuplicateClaim_NoDoubleEntry()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var actionId = Guid.NewGuid();
var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty<IStep>());
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
// First registration claims the placeholder.
var post1 = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object;
_debugger.OnPostStepRegistered(post1);
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
// Second registration with the same Action.Id but a
// different IStep: TryClaim returns null (already
// claimed). Falls through to Append. But the entry
// it builds matches no existing step, so a new entry
// would be added — UNLESS we constructed the second
// post as a duplicate IStep registration of the same
// step. Here we intentionally pass the same `post1`
// step a second time — Append will reject the
// already-registered step, the handler swallows it.
_debugger.OnPostStepRegistered(post1);
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
}
finally
{
await _debugger.StopAsync();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnStepCompleted_SkippedMainStep_MarksPostPlaceholder()
{
using (var hc = CreateTestContext())
{
hc.SetSingleton<IActionManager>(NewActionManagerWithPost("actions/has-post").Object);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
try
{
await DriveToReadyAsync(_debugger, port);
var actionId = Guid.NewGuid();
var mainMock = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId);
var execCtx = new Mock<IExecutionContext>();
execCtx.SetupGet(x => x.Result).Returns(TaskResult.Skipped);
mainMock.SetupGet(x => x.ExecutionContext).Returns(execCtx.Object);
await _debugger.OnJobStepsInitializedAsync(new[] { mainMock.Object }, Array.Empty<IStep>());
var view = _debugger.ExecutionView;
Assert.Equal(2, view.EntryCount); // main + predicted post placeholder
Assert.DoesNotContain("(skipped", view.Yaml);
_debugger.OnStepCompleted(mainMock.Object);
Assert.Equal(2, _debugger.ExecutionView.EntryCount);
Assert.Contains("(skipped — main step did not execute)", _debugger.ExecutionView.Yaml);
// Inline annotation must not have introduced a new line.
Assert.Equal(view.Yaml.Split('\n').Length, _debugger.ExecutionView.Yaml.Split('\n').Length);
}
finally
{
await _debugger.StopAsync();
}
}
}
}
}

View File

@@ -1,617 +0,0 @@
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<JobExecutionViewEntry> WorkedExampleEntries()
{
return new List<JobExecutionViewEntry>
{
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<JobExecutionViewEntry>());
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<Dictionary<string, List<Dictionary<string, object>>>>(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<JobExecutionViewEntry>());
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: <value> 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<ArgumentException>(
() => JobExecutionViewRenderer.Render(null, new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnWhitespaceJobId()
{
Assert.Throws<ArgumentException>(
() => JobExecutionViewRenderer.Render(" ", new List<JobExecutionViewEntry>()));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ThrowsOnNullEntries()
{
Assert.Throws<ArgumentNullException>(
() => 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<ArgumentException>(
() => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet()
{
Assert.Throws<ArgumentException>(
() => 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]);
}
}
}

View File

@@ -141,7 +141,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(_jobHookProvider.Object);
hc.SetSingleton(_snapshotOperationProvider.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job
@@ -761,171 +760,5 @@ namespace GitHub.Runner.Common.Tests.Worker
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerStartedInSetupJobWhenEnabled()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
// Verify DAP debugger was started and waited on
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Once);
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Once);
// Verify steps are still returned correctly
Assert.Equal(5, result.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerNotStartedInSetupJobWhenDisabled()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Debugger NOT enabled on the message — should not be started
// Set up mock debugger (should NOT be called)
var mockDebugger = new Mock<IDapDebugger>();
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
List<IStep> result = await jobExtension.InitializeJob(_jobEc, _message);
// Verify DAP debugger was NOT started during setup job
mockDebugger.Verify(x => x.StartAsync(It.IsAny<IExecutionContext>()), Times.Never);
mockDebugger.Verify(x => x.WaitUntilReadyAsync(), Times.Never);
Assert.Equal(5, result.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerCleanedUpInFinalizeJob()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.OnJobCompletedAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
// Run InitializeJob to start the debugger
await jobExtension.InitializeJob(_jobEc, _message);
// Run FinalizeJob — should pause (inside OnJobCompletedAsync) then clean up
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
// Verify OnJobCompletedAsync was called (it handles pause + cleanup)
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FinalizeJobHandlesDebuggerCleanupException()
{
using (TestHostContext hc = CreateTestContext())
{
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable debugger on the message
_message.EnableDebugger = true;
_message.DebuggerTunnel = new Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = 9229
};
// Re-initialize the execution context so it picks up debugger config
_jobEc = new Runner.Worker.ExecutionContext();
_jobEc.Initialize(hc);
_jobEc.InitializeJob(_message, _tokenSource.Token);
// Set up mock debugger — OnJobCompletedAsync throws
var mockDebugger = new Mock<IDapDebugger>();
mockDebugger.Setup(x => x.StartAsync(It.IsAny<IExecutionContext>())).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.WaitUntilReadyAsync()).Returns(Task.CompletedTask);
mockDebugger.Setup(x => x.OnJobCompletedAsync()).ThrowsAsync(new InvalidOperationException("tunnel disposed"));
mockDebugger.Setup(x => x.StopAsync()).Returns(Task.CompletedTask);
hc.SetSingleton(mockDebugger.Object);
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
// FinalizeJob should not throw even when OnJobCompletedAsync throws
await jobExtension.FinalizeJob(_jobEc, _message, DateTime.UtcNow);
mockDebugger.Verify(x => x.OnJobCompletedAsync(), Times.Once);
mockDebugger.Verify(x => x.StopAsync(), Times.Once);
}
}
}
}

View File

@@ -1,6 +1,5 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using System;
using System.Collections.Generic;
@@ -84,7 +83,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_extensions.Object);
hc.SetSingleton(_temp.Object);
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IExecutionContext>(_jobEc);
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
@@ -177,29 +175,5 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Override the lenient IDapDebugger singleton from CreateTestContext
// with a strict mock. If the containment guard fails, the production
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
var message = GetMessage();
// EnableDebugger defaults to false on AgentJobRequestMessage.
Assert.False(message.EnableDebugger);
await _jobRunner.RunAsync(message, _tokenSource.Token);
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
dapMock.VerifyNoOtherCalls();
}
}
}
}

View File

@@ -1,425 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class StepEntryTranslatorL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
{
var m = new MappingToken(null, null, null);
foreach (var (k, v) in pairs)
{
m.Add(Str(k), v);
}
return m;
}
private static Mock<IActionRunner> NewActionRunnerMock(
ActionRunStage stage,
string displayName,
ActionStepDefinitionReference reference,
ActionStep actionOverride = null)
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(stage);
mock.SetupGet(x => x.DisplayName).Returns(displayName);
mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
{
Reference = reference,
});
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_NullStep_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
StepEntryTranslator.TryTranslate(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_JobExtensionRunner_ReturnsNull()
{
var step = new JobExtensionRunner(
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
condition: null,
displayName: "Set up job",
data: null);
Assert.Null(StepEntryTranslator.TryTranslate(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_OtherIStepType_ReturnsNull()
{
var mock = new Mock<IStep>();
mock.SetupGet(x => x.DisplayName).Returns("custom");
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPre_ReturnsPreEntry()
{
var reference = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4",
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
Assert.Equal("actions/checkout@v4", entry.Uses);
Assert.Null(entry.Run);
Assert.Null(entry.SourcePath);
Assert.Equal(0, entry.SourceLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
{
var reference = new RepositoryPathReference
{
Name = "actions/setup-node",
Path = "subdir",
Ref = "v3",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
{
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Null(entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
{
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("alpine:3.18", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPost_ReturnsPostEntry()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
Assert.Equal("actions/cache@v3", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("anonymous", entry.DisplayName);
Assert.Null(entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("path: prime-numbers", entry.WithYaml);
Assert.Contains("key: k", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_PreservesExpressionInWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
Assert.DoesNotContain("Linux", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsScript()
{
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(("script", Str("echo hi"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Uses);
Assert.Equal("echo hi", entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
{
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(
("script", Str("npm test")),
("shell", Str("bash")),
("working-directory", Str("./api"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("npm test", entry.Run);
Assert.Equal("bash", entry.Shell);
Assert.Equal("./api", entry.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
{
// Defensive: an action step's Inputs should not contain
// run-step internal keys, but if it did, they must not
// surface in the with: rendering.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(
("mode", Str("ci")),
("script", Str("leak")),
("shell", Str("leak")),
("working-directory", Str("leak"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("mode: ci", entry.WithYaml);
Assert.DoesNotContain("leak", entry.WithYaml);
Assert.DoesNotContain("script", entry.WithYaml);
Assert.DoesNotContain("shell", entry.WithYaml);
Assert.DoesNotContain("working-directory", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_OmitsEmptyEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = new MappingToken(null, null, null),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.EnvYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = Map(("NODE_ENV", Str("production"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.EnvYaml);
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("__1")]
[InlineData("__123")]
public void Translate_FiltersAutoGeneratedId(string contextName)
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = contextName,
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreservesUserId()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "cache-primes",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("cache-primes", entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsCondition()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Condition = "always()",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("always()", entry.If);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreEntry_OmitsUserParams()
{
// Pre entries stay minimal — they reference the same Action as
// Main, and duplicating params adds noise.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "user-id",
Condition = "always()",
Environment = Map(("X", Str("y"))),
Inputs = Map(("k", Str("v"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Null(entry.Id);
Assert.Null(entry.If);
Assert.Null(entry.EnvYaml);
Assert.Null(entry.WithYaml);
}
}
}

View File

@@ -1,155 +0,0 @@
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class TemplateTokenYamlAdapterL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static BooleanToken Bool(bool b) => new(null, null, null, b);
private static NumberToken Num(double n) => new(null, null, null, n);
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_StringScalar()
{
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_BooleanScalar()
{
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NumberScalar()
{
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NullToken_RendersAsNull()
{
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesBasicExpression()
{
var token = Expr("runner.os");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}", yaml);
Assert.DoesNotContain("Linux", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesCompositeExpressionInStringToken()
{
// Composite strings like `${{ runner.os }}-primes` are parsed
// as a StringToken whose value is exactly that literal. The
// adapter must round-trip the literal unchanged.
var token = Str("${{ runner.os }}-primes");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}-primes", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NestedMapping()
{
var inner = new MappingToken(null, null, null);
inner.Add(Str("b"), Num(1));
inner.Add(Str("c"), Expr("x"));
var outer = new MappingToken(null, null, null);
outer.Add(Str("a"), inner);
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
Assert.Contains("a:", yaml);
Assert.Contains("b: 1", yaml);
Assert.Contains("c: ${{ x }}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptyMapping()
{
var token = new MappingToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("{}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptySequence()
{
var token = new SequenceToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("[]", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_MultilineString_UsesBlockScalar()
{
var token = Str("line1\nline2\nline3");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
// Block-literal indicator `|` appears for multi-line scalars.
Assert.Contains("|", yaml);
Assert.Contains("line1", yaml);
Assert.Contains("line2", yaml);
Assert.Contains("line3", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
{
var map = new MappingToken(null, null, null);
map.Add(Str("k1"), Str("v1"));
map.Add(Str("k2"), Str("v2"));
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
foreach (var line in yaml.Split('\n'))
{
if (line.Length > 0)
{
Assert.StartsWith(" ", line);
}
}
Assert.Contains("k1: v1", yaml);
Assert.Contains("k2: v2", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NoTrailingNewline()
{
var token = Str("hello");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.False(yaml.EndsWith("\n"));
}
}
}