mirror of
https://github.com/actions/runner.git
synced 2026-01-23 04:51:23 +08:00
editing jobs
This commit is contained in:
@@ -7,15 +7,15 @@
|
||||
|
||||
## Progress Checklist
|
||||
|
||||
- [ ] **Chunk 1:** Command Parser & Infrastructure
|
||||
- [ ] **Chunk 2:** Step Serializer (ActionStep → YAML)
|
||||
- [ ] **Chunk 3:** Step Factory (Create new steps)
|
||||
- [ ] **Chunk 4:** Step Manipulator (Queue operations)
|
||||
- [ ] **Chunk 5:** REPL Commands (!step list, !step add run, !step edit, !step remove, !step move)
|
||||
- [ ] **Chunk 6:** Action Download Integration (!step add uses)
|
||||
- [ ] **Chunk 7:** Export Command (!step export)
|
||||
- [ ] **Chunk 8:** JSON API for Browser Extension
|
||||
- [ ] **Chunk 9:** Browser Extension UI
|
||||
- [x] **Chunk 1:** Command Parser & Infrastructure
|
||||
- [x] **Chunk 2:** Step Serializer (ActionStep → YAML)
|
||||
- [x] **Chunk 3:** Step Factory (Create new steps)
|
||||
- [x] **Chunk 4:** Step Manipulator (Queue operations)
|
||||
- [x] **Chunk 5:** REPL Commands (!step list, !step add run, !step edit, !step remove, !step move)
|
||||
- [x] **Chunk 6:** Action Download Integration (!step add uses)
|
||||
- [x] **Chunk 7:** Export Command (!step export)
|
||||
- [x] **Chunk 8:** JSON API for Browser Extension
|
||||
- [x] **Chunk 9:** Browser Extension UI
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -613,3 +613,411 @@ html[data-color-mode="light"] .dap-debug-btn.selected {
|
||||
background-color: #ddf4ff;
|
||||
border-color: #54aeff;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Steps Panel
|
||||
========================================================================== */
|
||||
|
||||
.dap-steps-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--borderColor-default, #30363d);
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.dap-debugger-sidebar .dap-steps-panel {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--borderColor-default, #30363d);
|
||||
max-width: none;
|
||||
width: auto;
|
||||
max-height: 35%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.dap-steps-header {
|
||||
background-color: var(--bgColor-muted, #161b22);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dap-steps-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dap-steps-empty {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dap-steps-footer {
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bgColor-muted, #161b22);
|
||||
}
|
||||
|
||||
.dap-add-step-btn,
|
||||
.dap-export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dap-add-step-btn svg,
|
||||
.dap-export-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dap-add-step-btn:disabled,
|
||||
.dap-export-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Step Items */
|
||||
.dap-step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
gap: 4px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.dap-step-item:hover {
|
||||
background-color: var(--bgColor-muted, #161b22);
|
||||
}
|
||||
|
||||
.dap-step-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dap-step-item.current {
|
||||
background-color: var(--bgColor-accent-muted, #388bfd26);
|
||||
border-left-color: var(--fgColor-accent, #58a6ff);
|
||||
}
|
||||
|
||||
.dap-step-item.pending[data-editable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dap-step-status-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dap-step-status-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.dap-step-status-icon.completed {
|
||||
color: var(--fgColor-success, #3fb950);
|
||||
}
|
||||
|
||||
.dap-step-status-icon.current {
|
||||
color: var(--fgColor-accent, #58a6ff);
|
||||
}
|
||||
|
||||
.dap-step-status-icon.pending {
|
||||
color: var(--fgColor-muted, #8b949e);
|
||||
}
|
||||
|
||||
.dap-step-index {
|
||||
color: var(--fgColor-muted, #8b949e);
|
||||
font-size: 11px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.dap-step-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
}
|
||||
|
||||
.dap-step-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dap-step-badge-added {
|
||||
background-color: var(--bgColor-success-muted, #2ea04326);
|
||||
color: var(--fgColor-success, #3fb950);
|
||||
}
|
||||
|
||||
.dap-step-badge-modified {
|
||||
background-color: var(--bgColor-attention-muted, #bb800926);
|
||||
color: var(--fgColor-attention, #d29922);
|
||||
}
|
||||
|
||||
.dap-step-type {
|
||||
color: var(--fgColor-muted, #8b949e);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dap-step-menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fgColor-muted, #8b949e);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.dap-step-item:hover .dap-step-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dap-step-menu-btn:hover {
|
||||
background-color: var(--bgColor-neutral-muted, #6e768166);
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Context Menu
|
||||
========================================================================== */
|
||||
|
||||
.dap-context-menu {
|
||||
background-color: var(--bgColor-default, #0d1117);
|
||||
border: 1px solid var(--borderColor-default, #30363d);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dap-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dap-context-menu-item:hover {
|
||||
background-color: var(--bgColor-accent-muted, #388bfd26);
|
||||
}
|
||||
|
||||
.dap-context-menu-item.danger {
|
||||
color: var(--fgColor-danger, #f85149);
|
||||
}
|
||||
|
||||
.dap-context-menu-item.danger:hover {
|
||||
background-color: var(--bgColor-danger-muted, #da363326);
|
||||
}
|
||||
|
||||
.dap-context-menu-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dap-context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--borderColor-default, #30363d);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Modal
|
||||
========================================================================== */
|
||||
|
||||
.dap-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.dap-modal {
|
||||
background-color: var(--bgColor-default, #0d1117);
|
||||
border: 1px solid var(--borderColor-default, #30363d);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dap-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--borderColor-default, #30363d);
|
||||
}
|
||||
|
||||
.dap-modal-header .text-bold {
|
||||
font-size: 14px;
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
}
|
||||
|
||||
.dap-modal-close-btn {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: var(--fgColor-muted, #8b949e) !important;
|
||||
padding: 4px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dap-modal-close-btn:hover {
|
||||
color: var(--fgColor-default, #e6edf3) !important;
|
||||
}
|
||||
|
||||
.dap-modal-close-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dap-modal-content {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dap-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--borderColor-default, #30363d);
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.dap-form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dap-form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dap-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dap-modal .form-control {
|
||||
width: 100%;
|
||||
background-color: var(--bgColor-inset, #010409) !important;
|
||||
border-color: var(--borderColor-default, #30363d) !important;
|
||||
color: var(--fgColor-default, #e6edf3) !important;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dap-modal .form-control:focus {
|
||||
border-color: var(--focus-outlineColor, #1f6feb) !important;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
|
||||
}
|
||||
|
||||
.dap-modal select.form-control {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.dap-modal textarea.form-control {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Export Modal */
|
||||
.dap-export-stats {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dap-export-yaml {
|
||||
background-color: var(--bgColor-inset, #010409);
|
||||
border: 1px solid var(--borderColor-default, #30363d);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.dap-yaml-content {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--fgColor-default, #e6edf3);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Light Mode Overrides for New Components
|
||||
========================================================================== */
|
||||
|
||||
[data-color-mode="light"] .dap-context-menu,
|
||||
html[data-color-mode="light"] .dap-context-menu {
|
||||
background-color: #ffffff;
|
||||
border-color: #d0d7de;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-context-menu-item,
|
||||
html[data-color-mode="light"] .dap-context-menu-item {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-modal,
|
||||
html[data-color-mode="light"] .dap-modal {
|
||||
background-color: #ffffff;
|
||||
border-color: #d0d7de;
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-modal-header .text-bold,
|
||||
html[data-color-mode="light"] .dap-modal-header .text-bold {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-step-name,
|
||||
html[data-color-mode="light"] .dap-step-name {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-export-yaml,
|
||||
html[data-color-mode="light"] .dap-export-yaml {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
[data-color-mode="light"] .dap-yaml-content,
|
||||
html[data-color-mode="light"] .dap-yaml-content {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ let replHistory = [];
|
||||
let replHistoryIndex = -1;
|
||||
let currentLayout = 'bottom'; // 'bottom' | 'sidebar'
|
||||
let currentStepElement = null; // Track current step for breakpoint indicator
|
||||
let stepsList = []; // Cached steps from DAP
|
||||
let activeContextMenu = null; // Track open context menu
|
||||
let activeModal = null; // Track open modal
|
||||
|
||||
// Layout constants
|
||||
const BOTTOM_PANEL_HEIGHT = 350;
|
||||
@@ -132,6 +135,16 @@ const Icons = {
|
||||
stepBack: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V2H2zm3 6 7 5V3L5 8z"/></svg>`,
|
||||
continue: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M4 2l10 6-10 6z"/></svg>`,
|
||||
stepForward: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M12 2v12h2V2h-2zM2 3l7 5-7 5V3z"/></svg>`,
|
||||
plus: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`,
|
||||
download: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Zm-1.97-7.22a.75.75 0 0 0 1.06 1.06L7 2.69v8.56a.75.75 0 0 0 1.5 0V2.69l5.16 5.15a.75.75 0 1 0 1.06-1.06l-6.5-6.5a.75.75 0 0 0-1.06 0l-6.5 6.5Z" transform="rotate(180 8 8)"/></svg>`,
|
||||
check: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>`,
|
||||
play: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/></svg>`,
|
||||
clock: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>`,
|
||||
pencil: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
||||
trash: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
||||
arrowUp: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z"/></svg>`,
|
||||
arrowDown: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M13.03 8.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.47 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l2.97 2.97V3.75a.75.75 0 0 1 1.5 0v7.44l2.97-2.97a.75.75 0 0 1 1.06 0Z"/></svg>`,
|
||||
copy: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -155,6 +168,31 @@ function createControlButtonsHTML(compact = false) {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the steps panel HTML
|
||||
*/
|
||||
function createStepsPanelHTML() {
|
||||
return `
|
||||
<div class="dap-steps-panel">
|
||||
<div class="dap-steps-header p-2 text-bold border-bottom d-flex flex-items-center">
|
||||
<span>Steps</span>
|
||||
<button class="btn btn-sm dap-add-step-btn ml-auto" title="Add Step" disabled>
|
||||
${Icons.plus}
|
||||
</button>
|
||||
</div>
|
||||
<div class="dap-steps-list overflow-auto flex-auto">
|
||||
<div class="dap-steps-empty p-2 color-fg-muted">Connect to view steps</div>
|
||||
</div>
|
||||
<div class="dap-steps-footer p-2 border-top">
|
||||
<button class="btn btn-sm dap-export-btn" title="Export Changes" disabled>
|
||||
${Icons.download}
|
||||
<span class="ml-1">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the bottom panel HTML structure
|
||||
*/
|
||||
@@ -185,6 +223,9 @@ function createBottomPaneHTML() {
|
||||
</div>
|
||||
|
||||
<div class="dap-content d-flex">
|
||||
<!-- Steps Panel -->
|
||||
${createStepsPanelHTML()}
|
||||
|
||||
<!-- Scopes Panel -->
|
||||
<div class="dap-scopes border-right overflow-auto">
|
||||
<div class="dap-scope-header p-2 text-bold border-bottom d-flex flex-items-center">
|
||||
@@ -236,6 +277,9 @@ function createSidebarPaneHTML() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps Panel -->
|
||||
${createStepsPanelHTML()}
|
||||
|
||||
<!-- Scopes Panel -->
|
||||
<div class="dap-scopes overflow-auto border-bottom">
|
||||
<div class="dap-scope-header p-2 text-bold border-bottom d-flex flex-items-center">
|
||||
@@ -382,6 +426,10 @@ function closeDebuggerPane() {
|
||||
// Remove body padding class
|
||||
document.body.classList.remove('dap-bottom-panel-active');
|
||||
|
||||
// Close any open context menu or modal
|
||||
closeContextMenu();
|
||||
closeModal();
|
||||
|
||||
if (debuggerPane) {
|
||||
debuggerPane.remove();
|
||||
debuggerPane = null;
|
||||
@@ -392,6 +440,628 @@ function closeDebuggerPane() {
|
||||
if (btn) btn.classList.remove('selected');
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Step Manipulation UI
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get status icon for step
|
||||
*/
|
||||
function getStepStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return `<span class="dap-step-status-icon completed">${Icons.check}</span>`;
|
||||
case 'current':
|
||||
return `<span class="dap-step-status-icon current">${Icons.play}</span>`;
|
||||
case 'pending':
|
||||
default:
|
||||
return `<span class="dap-step-status-icon pending">${Icons.clock}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render step list from data
|
||||
*/
|
||||
function renderStepList(steps) {
|
||||
const container = debuggerPane?.querySelector('.dap-steps-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
container.innerHTML = '<div class="dap-steps-empty p-2 color-fg-muted">No steps available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
stepsList = steps;
|
||||
|
||||
const html = steps.map((step) => {
|
||||
const statusIcon = getStepStatusIcon(step.status);
|
||||
const changeBadge = step.change ? `<span class="dap-step-badge dap-step-badge-${step.change.toLowerCase()}">[${step.change}]</span>` : '';
|
||||
const typeLabel = step.type === 'uses' ? step.typeDetail || step.type : step.type;
|
||||
const isPending = step.status === 'pending';
|
||||
|
||||
return `
|
||||
<div class="dap-step-item ${step.status}" data-index="${step.index}" ${isPending ? 'data-editable="true"' : ''}>
|
||||
${statusIcon}
|
||||
<span class="dap-step-index">${step.index}.</span>
|
||||
<span class="dap-step-name" title="${escapeHtml(step.name)}">${escapeHtml(step.name)}</span>
|
||||
${changeBadge}
|
||||
<span class="dap-step-type">${escapeHtml(typeLabel)}</span>
|
||||
${isPending ? `<button class="dap-step-menu-btn" title="Step actions">...</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers for step menu buttons
|
||||
container.querySelectorAll('.dap-step-menu-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const stepItem = btn.closest('.dap-step-item');
|
||||
const index = parseInt(stepItem.dataset.index);
|
||||
showStepContextMenu(index, e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load steps from DAP server
|
||||
*/
|
||||
async function loadSteps() {
|
||||
try {
|
||||
const response = await sendDapRequest('evaluate', {
|
||||
expression: JSON.stringify({ cmd: 'step.list', verbose: false }),
|
||||
frameId: currentFrameId,
|
||||
context: 'repl',
|
||||
});
|
||||
|
||||
if (response.result) {
|
||||
try {
|
||||
const result = JSON.parse(response.result);
|
||||
if (result.Success && result.Result) {
|
||||
renderStepList(result.Result);
|
||||
enableStepControls(true);
|
||||
return;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Response might not be JSON, that's ok
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show empty state
|
||||
renderStepList([]);
|
||||
} catch (error) {
|
||||
console.error('[Content] Failed to load steps:', error);
|
||||
const container = debuggerPane?.querySelector('.dap-steps-list');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="dap-steps-empty p-2 color-fg-muted">Failed to load steps</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable step manipulation controls
|
||||
*/
|
||||
function enableStepControls(enabled) {
|
||||
if (!debuggerPane) return;
|
||||
|
||||
const addBtn = debuggerPane.querySelector('.dap-add-step-btn');
|
||||
const exportBtn = debuggerPane.querySelector('.dap-export-btn');
|
||||
|
||||
if (addBtn) addBtn.disabled = !enabled;
|
||||
if (exportBtn) exportBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send step command via JSON API
|
||||
*/
|
||||
async function sendStepCommand(cmd, options = {}) {
|
||||
const command = { cmd, ...options };
|
||||
try {
|
||||
const response = await sendDapRequest('evaluate', {
|
||||
expression: JSON.stringify(command),
|
||||
frameId: currentFrameId,
|
||||
context: 'repl',
|
||||
});
|
||||
|
||||
if (response.result) {
|
||||
try {
|
||||
return JSON.parse(response.result);
|
||||
} catch (e) {
|
||||
return { Success: false, Error: 'PARSE_ERROR', Message: response.result };
|
||||
}
|
||||
}
|
||||
return { Success: false, Error: 'NO_RESPONSE', Message: 'No response from server' };
|
||||
} catch (error) {
|
||||
return { Success: false, Error: 'REQUEST_FAILED', Message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu for a step
|
||||
*/
|
||||
function showStepContextMenu(stepIndex, event) {
|
||||
closeContextMenu();
|
||||
|
||||
const step = stepsList.find((s) => s.index === stepIndex);
|
||||
if (!step || step.status !== 'pending') return;
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'dap-context-menu';
|
||||
menu.innerHTML = `
|
||||
<div class="dap-context-menu-item" data-action="edit">
|
||||
${Icons.pencil}
|
||||
<span>Edit</span>
|
||||
</div>
|
||||
<div class="dap-context-menu-item" data-action="moveUp">
|
||||
${Icons.arrowUp}
|
||||
<span>Move Up</span>
|
||||
</div>
|
||||
<div class="dap-context-menu-item" data-action="moveDown">
|
||||
${Icons.arrowDown}
|
||||
<span>Move Down</span>
|
||||
</div>
|
||||
<div class="dap-context-menu-divider"></div>
|
||||
<div class="dap-context-menu-item danger" data-action="delete">
|
||||
${Icons.trash}
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position menu
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.left = `${rect.left}px`;
|
||||
|
||||
// Add event handlers
|
||||
menu.querySelectorAll('.dap-context-menu-item').forEach((item) => {
|
||||
item.addEventListener('click', async () => {
|
||||
const action = item.dataset.action;
|
||||
closeContextMenu();
|
||||
await handleStepAction(action, stepIndex);
|
||||
});
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeContextMenu = menu;
|
||||
|
||||
// Close menu on outside click
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeContextMenuOnOutsideClick);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close context menu
|
||||
*/
|
||||
function closeContextMenu() {
|
||||
if (activeContextMenu) {
|
||||
activeContextMenu.remove();
|
||||
activeContextMenu = null;
|
||||
}
|
||||
document.removeEventListener('click', closeContextMenuOnOutsideClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close context menu on outside click
|
||||
*/
|
||||
function closeContextMenuOnOutsideClick(e) {
|
||||
if (activeContextMenu && !activeContextMenu.contains(e.target)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle step action from context menu
|
||||
*/
|
||||
async function handleStepAction(action, stepIndex) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
showEditStepDialog(stepIndex);
|
||||
break;
|
||||
case 'moveUp':
|
||||
await moveStep(stepIndex, 'before', stepIndex - 1);
|
||||
break;
|
||||
case 'moveDown':
|
||||
await moveStep(stepIndex, 'after', stepIndex + 1);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteStep(stepIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a step
|
||||
*/
|
||||
async function moveStep(fromIndex, positionType, targetIndex) {
|
||||
const position = {};
|
||||
position[positionType] = targetIndex;
|
||||
|
||||
const result = await sendStepCommand('step.move', { from: fromIndex, position });
|
||||
if (result.Success) {
|
||||
appendOutput(`Step moved successfully`, 'result');
|
||||
await loadSteps();
|
||||
} else {
|
||||
appendOutput(`Failed to move step: ${result.Message || result.Error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a step
|
||||
*/
|
||||
async function deleteStep(stepIndex) {
|
||||
const step = stepsList.find((s) => s.index === stepIndex);
|
||||
if (!step) return;
|
||||
|
||||
// Simple confirmation
|
||||
if (!confirm(`Delete step "${step.name}"?`)) return;
|
||||
|
||||
const result = await sendStepCommand('step.remove', { index: stepIndex });
|
||||
if (result.Success) {
|
||||
appendOutput(`Step removed successfully`, 'result');
|
||||
await loadSteps();
|
||||
} else {
|
||||
appendOutput(`Failed to remove step: ${result.Message || result.Error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
function closeModal() {
|
||||
if (activeModal) {
|
||||
activeModal.remove();
|
||||
activeModal = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create modal wrapper
|
||||
*/
|
||||
function createModal(title, content, buttons = []) {
|
||||
closeModal();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'dap-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="dap-modal">
|
||||
<div class="dap-modal-header">
|
||||
<span class="text-bold">${escapeHtml(title)}</span>
|
||||
<button class="btn btn-sm dap-modal-close-btn">${Icons.close}</button>
|
||||
</div>
|
||||
<div class="dap-modal-content">
|
||||
${content}
|
||||
</div>
|
||||
<div class="dap-modal-footer">
|
||||
${buttons.map((btn) => `<button class="btn btn-sm ${btn.primary ? 'btn-primary' : ''}" data-action="${btn.action}">${btn.label}</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
// Close button
|
||||
modal.querySelector('.dap-modal-close-btn').addEventListener('click', closeModal);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
activeModal = modal;
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Add Step dialog
|
||||
*/
|
||||
function showAddStepDialog() {
|
||||
const content = `
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Step Type</label>
|
||||
<select class="form-control dap-step-type-select">
|
||||
<option value="run">Run (shell command)</option>
|
||||
<option value="uses">Uses (action)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group dap-run-fields">
|
||||
<label class="dap-label">Script</label>
|
||||
<textarea class="form-control dap-script-input" rows="4" placeholder="echo 'Hello World'"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group dap-run-fields">
|
||||
<label class="dap-label">Shell (optional)</label>
|
||||
<select class="form-control dap-shell-select">
|
||||
<option value="">Default</option>
|
||||
<option value="bash">bash</option>
|
||||
<option value="sh">sh</option>
|
||||
<option value="pwsh">pwsh</option>
|
||||
<option value="powershell">powershell</option>
|
||||
<option value="cmd">cmd</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group dap-uses-fields" style="display: none;">
|
||||
<label class="dap-label">Action</label>
|
||||
<input type="text" class="form-control dap-action-input" placeholder="actions/checkout@v4">
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group dap-uses-fields" style="display: none;">
|
||||
<label class="dap-label">With (key=value, one per line)</label>
|
||||
<textarea class="form-control dap-with-input" rows="3" placeholder="node-version=20 cache=npm"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Name (optional)</label>
|
||||
<input type="text" class="form-control dap-name-input" placeholder="Step name">
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Position</label>
|
||||
<select class="form-control dap-position-select">
|
||||
<option value="last">At end (default)</option>
|
||||
<option value="first">At first pending position</option>
|
||||
<option value="after">After current step</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = createModal('Add Step', content, [
|
||||
{ label: 'Cancel', action: 'cancel' },
|
||||
{ label: 'Add Step', action: 'add', primary: true },
|
||||
]);
|
||||
|
||||
// Type select handler
|
||||
const typeSelect = modal.querySelector('.dap-step-type-select');
|
||||
const runFields = modal.querySelectorAll('.dap-run-fields');
|
||||
const usesFields = modal.querySelectorAll('.dap-uses-fields');
|
||||
|
||||
typeSelect.addEventListener('change', () => {
|
||||
const isRun = typeSelect.value === 'run';
|
||||
runFields.forEach((f) => (f.style.display = isRun ? '' : 'none'));
|
||||
usesFields.forEach((f) => (f.style.display = isRun ? 'none' : ''));
|
||||
});
|
||||
|
||||
// Button handlers
|
||||
modal.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'cancel') {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
await handleAddStep(modal);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle add step form submission
|
||||
*/
|
||||
async function handleAddStep(modal) {
|
||||
const type = modal.querySelector('.dap-step-type-select').value;
|
||||
const name = modal.querySelector('.dap-name-input').value.trim() || undefined;
|
||||
const positionSelect = modal.querySelector('.dap-position-select').value;
|
||||
|
||||
let position = {};
|
||||
if (positionSelect === 'first') {
|
||||
position.first = true;
|
||||
} else if (positionSelect === 'after') {
|
||||
// After current step - find current step index
|
||||
const currentStep = stepsList.find((s) => s.status === 'current');
|
||||
if (currentStep) {
|
||||
position.after = currentStep.index;
|
||||
} else {
|
||||
position.last = true;
|
||||
}
|
||||
} else {
|
||||
position.last = true;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (type === 'run') {
|
||||
const script = modal.querySelector('.dap-script-input').value;
|
||||
const shell = modal.querySelector('.dap-shell-select').value || undefined;
|
||||
|
||||
if (!script.trim()) {
|
||||
alert('Script is required');
|
||||
return;
|
||||
}
|
||||
|
||||
result = await sendStepCommand('step.add', {
|
||||
type: 'run',
|
||||
script,
|
||||
name,
|
||||
shell,
|
||||
position,
|
||||
});
|
||||
} else {
|
||||
const action = modal.querySelector('.dap-action-input').value.trim();
|
||||
const withText = modal.querySelector('.dap-with-input').value.trim();
|
||||
|
||||
if (!action) {
|
||||
alert('Action is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse with inputs
|
||||
const withInputs = {};
|
||||
if (withText) {
|
||||
withText.split('\n').forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
withInputs[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
result = await sendStepCommand('step.add', {
|
||||
type: 'uses',
|
||||
action,
|
||||
name,
|
||||
with: Object.keys(withInputs).length > 0 ? withInputs : undefined,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.Success) {
|
||||
closeModal();
|
||||
appendOutput(`Step added at position ${result.Result?.index || 'end'}`, 'result');
|
||||
await loadSteps();
|
||||
} else {
|
||||
appendOutput(`Failed to add step: ${result.Message || result.Error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Edit Step dialog
|
||||
*/
|
||||
function showEditStepDialog(stepIndex) {
|
||||
const step = stepsList.find((s) => s.index === stepIndex);
|
||||
if (!step) return;
|
||||
|
||||
const isRun = step.type === 'run';
|
||||
|
||||
const content = `
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Step Type</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(step.type)}" disabled>
|
||||
</div>
|
||||
|
||||
${isRun ? `
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Script</label>
|
||||
<textarea class="form-control dap-edit-script-input" rows="4">${escapeHtml(step.typeDetail || '')}</textarea>
|
||||
</div>
|
||||
` : `
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Action</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(step.typeDetail || '')}" disabled>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Name</label>
|
||||
<input type="text" class="form-control dap-edit-name-input" value="${escapeHtml(step.name)}">
|
||||
</div>
|
||||
|
||||
<div class="dap-form-group">
|
||||
<label class="dap-label">Condition (optional)</label>
|
||||
<input type="text" class="form-control dap-edit-condition-input" placeholder="success()">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = createModal(`Edit Step ${stepIndex}`, content, [
|
||||
{ label: 'Cancel', action: 'cancel' },
|
||||
{ label: 'Save Changes', action: 'save', primary: true },
|
||||
]);
|
||||
|
||||
// Button handlers
|
||||
modal.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'cancel') {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'save') {
|
||||
await handleEditStep(modal, stepIndex, isRun);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle edit step form submission
|
||||
*/
|
||||
async function handleEditStep(modal, stepIndex, isRun) {
|
||||
const name = modal.querySelector('.dap-edit-name-input').value.trim() || undefined;
|
||||
const condition = modal.querySelector('.dap-edit-condition-input')?.value.trim() || undefined;
|
||||
|
||||
const options = { index: stepIndex };
|
||||
if (name) options.name = name;
|
||||
if (condition) options.if = condition;
|
||||
|
||||
if (isRun) {
|
||||
const script = modal.querySelector('.dap-edit-script-input')?.value;
|
||||
if (script) options.script = script;
|
||||
}
|
||||
|
||||
const result = await sendStepCommand('step.edit', options);
|
||||
|
||||
if (result.Success) {
|
||||
closeModal();
|
||||
appendOutput(`Step ${stepIndex} updated`, 'result');
|
||||
await loadSteps();
|
||||
} else {
|
||||
appendOutput(`Failed to edit step: ${result.Message || result.Error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Export modal
|
||||
*/
|
||||
async function showExportModal() {
|
||||
const result = await sendStepCommand('step.export', { changesOnly: false, withComments: true });
|
||||
|
||||
if (!result.Success) {
|
||||
appendOutput(`Failed to export: ${result.Message || result.Error}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const yaml = result.Message || result.Result?.yaml || '';
|
||||
const stats = result.Result || {};
|
||||
|
||||
const content = `
|
||||
<div class="dap-export-stats mb-2 color-fg-muted">
|
||||
Total steps: ${stats.totalSteps || 0} |
|
||||
Added: ${stats.addedCount || 0} |
|
||||
Modified: ${stats.modifiedCount || 0}
|
||||
</div>
|
||||
<div class="dap-export-yaml">
|
||||
<pre class="dap-yaml-content">${escapeHtml(yaml)}</pre>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = createModal('Export Steps', content, [
|
||||
{ label: 'Close', action: 'close' },
|
||||
{ label: 'Copy to Clipboard', action: 'copy', primary: true },
|
||||
]);
|
||||
|
||||
// Store YAML for copy
|
||||
modal.dataset.yaml = yaml;
|
||||
|
||||
// Button handlers
|
||||
modal.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'close') {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(modal.dataset.yaml);
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = `${Icons.copy} Copy to Clipboard`;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
appendOutput('Failed to copy to clipboard', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for debugger pane
|
||||
*/
|
||||
@@ -435,6 +1105,18 @@ function setupPaneEventHandlers(pane) {
|
||||
if (input) {
|
||||
input.addEventListener('keydown', handleReplKeydown);
|
||||
}
|
||||
|
||||
// Add Step button
|
||||
const addStepBtn = pane.querySelector('.dap-add-step-btn');
|
||||
if (addStepBtn) {
|
||||
addStepBtn.addEventListener('click', showAddStepDialog);
|
||||
}
|
||||
|
||||
// Export button
|
||||
const exportBtn = pane.querySelector('.dap-export-btn');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', showExportModal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,6 +1204,9 @@ function enableControls(enabled) {
|
||||
if (input) {
|
||||
input.disabled = !enabled;
|
||||
}
|
||||
|
||||
// Also enable step controls when debugger is enabled
|
||||
enableStepControls(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -729,6 +1414,9 @@ async function handleStoppedEvent(body) {
|
||||
|
||||
// Load scopes
|
||||
await loadScopes(currentFrame.id);
|
||||
|
||||
// Load steps for step manipulation panel
|
||||
await loadSteps();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Content] Failed to get stack trace:', error);
|
||||
@@ -790,6 +1478,9 @@ async function loadCurrentDebugState() {
|
||||
|
||||
// Load scopes
|
||||
await loadScopes(currentFrame.id);
|
||||
|
||||
// Load steps for step manipulation panel
|
||||
await loadSteps();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Content] Failed to load current debug state:', error);
|
||||
|
||||
@@ -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.StepCommands;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
@@ -282,9 +283,21 @@ namespace GitHub.Runner.Worker.Dap
|
||||
private readonly List<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
private int _nextCompletedFrameId = CompletedFrameIdBase;
|
||||
|
||||
// Track completed IStep objects for the step manipulator
|
||||
private readonly List<IStep> _completedStepsTracker = new List<IStep>();
|
||||
|
||||
// Variable provider for converting contexts to DAP variables
|
||||
private DapVariableProvider _variableProvider;
|
||||
|
||||
// Step command parser for !step REPL commands
|
||||
private IStepCommandParser _stepCommandParser;
|
||||
|
||||
// Step command handler for executing step commands
|
||||
private IStepCommandHandler _stepCommandHandler;
|
||||
|
||||
// Step manipulator for queue operations
|
||||
private IStepManipulator _stepManipulator;
|
||||
|
||||
// Checkpoint storage for step-back (time-travel) debugging
|
||||
private readonly List<StepCheckpoint> _checkpoints = new List<StepCheckpoint>();
|
||||
private const int MaxCheckpoints = 50;
|
||||
@@ -319,6 +332,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_variableProvider = new DapVariableProvider(hostContext);
|
||||
_stepCommandParser = hostContext.GetService<IStepCommandParser>();
|
||||
_stepCommandHandler = hostContext.GetService<IStepCommandHandler>();
|
||||
_stepManipulator = hostContext.GetService<IStepManipulator>();
|
||||
Trace.Info("DapDebugSession initialized");
|
||||
}
|
||||
|
||||
@@ -661,6 +677,12 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return HandleDebugCommand(expression);
|
||||
}
|
||||
|
||||
// Check for !step command (step manipulation commands)
|
||||
if (_stepCommandParser.IsStepCommand(expression))
|
||||
{
|
||||
return await HandleStepCommandAsync(expression);
|
||||
}
|
||||
|
||||
// Get the current execution context
|
||||
var executionContext = _currentStep?.ExecutionContext ?? _jobContext;
|
||||
if (executionContext == null)
|
||||
@@ -1096,6 +1118,81 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step manipulation commands (!step ...).
|
||||
/// Parses and executes commands using the StepCommandHandler.
|
||||
/// </summary>
|
||||
private async Task<Response> HandleStepCommandAsync(string expression)
|
||||
{
|
||||
Trace.Info($"Handling step command: {expression}");
|
||||
|
||||
try
|
||||
{
|
||||
var command = _stepCommandParser.Parse(expression);
|
||||
|
||||
// Ensure manipulator is initialized with current context
|
||||
EnsureManipulatorInitialized();
|
||||
|
||||
// Execute the command
|
||||
var result = await _stepCommandHandler.HandleAsync(command, _jobContext);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Return appropriate response format based on input type
|
||||
if (command.WasJsonInput)
|
||||
{
|
||||
return CreateSuccessResponse(new EvaluateResponseBody
|
||||
{
|
||||
Result = Newtonsoft.Json.JsonConvert.SerializeObject(result),
|
||||
Type = "json",
|
||||
VariablesReference = 0
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return CreateSuccessResponse(new EvaluateResponseBody
|
||||
{
|
||||
Result = result.Message,
|
||||
Type = "string",
|
||||
VariablesReference = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return CreateErrorResponse($"[{result.Error}] {result.Message}");
|
||||
}
|
||||
}
|
||||
catch (StepCommandException ex)
|
||||
{
|
||||
Trace.Warning($"Step command error: {ex.ErrorCode} - {ex.Message}");
|
||||
|
||||
return CreateErrorResponse($"[{ex.ErrorCode}] {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Step command failed: {ex}");
|
||||
|
||||
return CreateErrorResponse($"Step command failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the step manipulator is initialized with the current job context.
|
||||
/// </summary>
|
||||
private void EnsureManipulatorInitialized()
|
||||
{
|
||||
if (_jobContext == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"No job context available. Wait for the first step to start.");
|
||||
}
|
||||
|
||||
// The manipulator should already be initialized from OnStepStartingAsync
|
||||
// but just ensure the handler has the reference
|
||||
_stepCommandHandler.SetManipulator(_stepManipulator);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// Stub - breakpoints not implemented in demo
|
||||
@@ -1187,6 +1284,9 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_jobContext = jobContext;
|
||||
_jobCancellationToken = cancellationToken; // Store for REPL commands
|
||||
|
||||
// Initialize or update the step manipulator
|
||||
InitializeStepManipulator(step, isFirstStep);
|
||||
|
||||
// Hook up StepsContext debug logging (do this once when we first get jobContext)
|
||||
if (jobContext.Global.StepsContext.OnDebugLog == null)
|
||||
{
|
||||
@@ -1233,6 +1333,28 @@ namespace GitHub.Runner.Worker.Dap
|
||||
await WaitForCommandAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes or updates the step manipulator with the current state.
|
||||
/// </summary>
|
||||
private void InitializeStepManipulator(IStep currentStep, bool isFirstStep)
|
||||
{
|
||||
if (isFirstStep)
|
||||
{
|
||||
// First step - initialize fresh
|
||||
_stepManipulator.Initialize(_jobContext, 0);
|
||||
(_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep);
|
||||
_stepCommandHandler.SetManipulator(_stepManipulator);
|
||||
_stepManipulator.RecordOriginalState();
|
||||
Trace.Info("Step manipulator initialized for debug session");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update current step reference
|
||||
(_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep);
|
||||
_stepManipulator.UpdateCurrentIndex(_completedStepsTracker.Count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnStepCompleted(IStep step)
|
||||
{
|
||||
if (!IsActive)
|
||||
@@ -1269,6 +1391,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
|
||||
// Track IStep for the step manipulator
|
||||
_completedStepsTracker.Add(step);
|
||||
_stepManipulator?.AddCompletedStep(step);
|
||||
|
||||
// Clear current step reference since it's done
|
||||
// (will be set again when next step starts)
|
||||
}
|
||||
@@ -1604,6 +1730,16 @@ namespace GitHub.Runner.Worker.Dap
|
||||
_completedSteps.RemoveAt(_completedSteps.Count - 1);
|
||||
}
|
||||
|
||||
// Also clear the step tracker for manipulator sync
|
||||
while (_completedStepsTracker.Count > checkpointIndex)
|
||||
{
|
||||
_completedStepsTracker.RemoveAt(_completedStepsTracker.Count - 1);
|
||||
}
|
||||
|
||||
// Reset the step manipulator to match the restored state
|
||||
// It will be re-initialized when the restored step starts
|
||||
_stepManipulator?.ClearChanges();
|
||||
|
||||
// Store restored checkpoint for StepsRunner to consume
|
||||
_restoredCheckpoint = checkpoint;
|
||||
|
||||
|
||||
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
132
src/Runner.Worker/Dap/StepCommands/StepChange.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a modification made to a step during a debug session.
|
||||
/// Used for change tracking and export diff generation.
|
||||
/// </summary>
|
||||
public class StepChange
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of change made.
|
||||
/// </summary>
|
||||
public ChangeType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The original 1-based index of the step (before any modifications).
|
||||
/// For Added steps, this is -1.
|
||||
/// </summary>
|
||||
public int OriginalIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The current 1-based index of the step (after all modifications).
|
||||
/// For Removed steps, this is -1.
|
||||
/// </summary>
|
||||
public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the step before modification (for Modified/Moved/Removed).
|
||||
/// </summary>
|
||||
public StepInfo OriginalStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The step after modification (for Added/Modified/Moved).
|
||||
/// For Removed steps, this is null.
|
||||
/// </summary>
|
||||
public StepInfo ModifiedStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the change was made.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the change for display purposes.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for an added step.
|
||||
/// </summary>
|
||||
public static StepChange Added(StepInfo step, int index)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Added,
|
||||
OriginalIndex = -1,
|
||||
CurrentIndex = index,
|
||||
ModifiedStep = step,
|
||||
Description = $"Added step '{step.Name}' at position {index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a modified step.
|
||||
/// </summary>
|
||||
public static StepChange Modified(StepInfo original, StepInfo modified, string changeDescription = null)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Modified,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = modified.Index,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = changeDescription ?? $"Modified step '{original.Name}' at position {original.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a removed step.
|
||||
/// </summary>
|
||||
public static StepChange Removed(StepInfo step)
|
||||
{
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Removed,
|
||||
OriginalIndex = step.Index,
|
||||
CurrentIndex = -1,
|
||||
OriginalStep = step,
|
||||
Description = $"Removed step '{step.Name}' from position {step.Index}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a change record for a moved step.
|
||||
/// </summary>
|
||||
public static StepChange Moved(StepInfo original, int newIndex)
|
||||
{
|
||||
var modified = new StepInfo
|
||||
{
|
||||
Index = newIndex,
|
||||
Name = original.Name,
|
||||
Type = original.Type,
|
||||
TypeDetail = original.TypeDetail,
|
||||
Status = original.Status,
|
||||
Action = original.Action,
|
||||
Step = original.Step,
|
||||
OriginalIndex = original.Index,
|
||||
Change = ChangeType.Moved
|
||||
};
|
||||
|
||||
return new StepChange
|
||||
{
|
||||
Type = ChangeType.Moved,
|
||||
OriginalIndex = original.Index,
|
||||
CurrentIndex = newIndex,
|
||||
OriginalStep = original,
|
||||
ModifiedStep = modified,
|
||||
Description = $"Moved step '{original.Name}' from position {original.Index} to {newIndex}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable summary of this change.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return Description ?? $"{Type} step at index {OriginalIndex} -> {CurrentIndex}";
|
||||
}
|
||||
}
|
||||
}
|
||||
782
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
782
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs
Normal file
@@ -0,0 +1,782 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json;
|
||||
using DistributedTask = GitHub.DistributedTask;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for handling step manipulation commands.
|
||||
/// Executes parsed commands using the StepManipulator, StepFactory, and StepSerializer.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepCommandHandler))]
|
||||
public interface IStepCommandHandler : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles a parsed step command and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="command">The parsed command to execute</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <returns>Result of the command execution</returns>
|
||||
Task<StepCommandResult> HandleAsync(StepCommand command, IExecutionContext jobContext);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the handler with required services.
|
||||
/// Called when the debug session starts.
|
||||
/// </summary>
|
||||
/// <param name="manipulator">The step manipulator to use</param>
|
||||
void SetManipulator(IStepManipulator manipulator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles step manipulation commands (list, add, edit, remove, move, export).
|
||||
/// </summary>
|
||||
public sealed class StepCommandHandler : RunnerService, IStepCommandHandler
|
||||
{
|
||||
private IStepManipulator _manipulator;
|
||||
private IStepFactory _factory;
|
||||
private IStepSerializer _serializer;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_factory = hostContext.GetService<IStepFactory>();
|
||||
_serializer = hostContext.GetService<IStepSerializer>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetManipulator(IStepManipulator manipulator)
|
||||
{
|
||||
_manipulator = manipulator;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<StepCommandResult> HandleAsync(StepCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateContext(jobContext);
|
||||
|
||||
var result = command switch
|
||||
{
|
||||
ListCommand list => HandleList(list),
|
||||
AddRunCommand addRun => HandleAddRun(addRun, jobContext),
|
||||
AddUsesCommand addUses => await HandleAddUsesAsync(addUses, jobContext),
|
||||
EditCommand edit => HandleEdit(edit),
|
||||
RemoveCommand remove => HandleRemove(remove),
|
||||
MoveCommand move => HandleMove(move),
|
||||
ExportCommand export => HandleExport(export),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand,
|
||||
$"Unknown command type: {command.GetType().Name}")
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (StepCommandException ex)
|
||||
{
|
||||
return ex.ToResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Step command failed: {ex}");
|
||||
return StepCommandResult.Fail(StepCommandErrors.InvalidCommand, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#region Command Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step list command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleList(ListCommand command)
|
||||
{
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var output = FormatStepList(steps, command.Verbose);
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = output,
|
||||
Result = new
|
||||
{
|
||||
steps = steps.Select(s => new
|
||||
{
|
||||
index = s.Index,
|
||||
name = s.Name,
|
||||
type = s.Type,
|
||||
typeDetail = s.TypeDetail,
|
||||
status = s.Status.ToString().ToLower(),
|
||||
change = s.Change?.ToString().ToUpper()
|
||||
}).ToList(),
|
||||
totalCount = steps.Count,
|
||||
completedCount = steps.Count(s => s.Status == StepStatus.Completed),
|
||||
pendingCount = steps.Count(s => s.Status == StepStatus.Pending)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the step list for REPL display.
|
||||
/// </summary>
|
||||
private string FormatStepList(IReadOnlyList<StepInfo> steps, bool verbose)
|
||||
{
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return "No steps in the job.";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Steps:");
|
||||
|
||||
var maxNameLength = steps.Max(s => s.Name?.Length ?? 0);
|
||||
maxNameLength = Math.Min(maxNameLength, 40); // Cap at 40 chars
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
// Status indicator
|
||||
var statusIcon = step.Status switch
|
||||
{
|
||||
StepStatus.Completed => "\u2713", // checkmark
|
||||
StepStatus.Current => "\u25B6", // play arrow
|
||||
StepStatus.Pending => " ",
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
// Change indicator
|
||||
var changeTag = step.Change.HasValue
|
||||
? $" [{step.Change.Value.ToString().ToUpper()}]"
|
||||
: "";
|
||||
|
||||
// Truncate name if needed
|
||||
var displayName = step.Name ?? "";
|
||||
if (displayName.Length > maxNameLength)
|
||||
{
|
||||
displayName = displayName.Substring(0, maxNameLength - 3) + "...";
|
||||
}
|
||||
|
||||
// Build the line
|
||||
var line = $" {statusIcon} {step.Index,2}. {displayName.PadRight(maxNameLength)}{changeTag,-12} {step.Type,-5} {step.TypeDetail}";
|
||||
|
||||
if (verbose && step.Action != null)
|
||||
{
|
||||
// Add verbose details
|
||||
sb.AppendLine(line);
|
||||
if (!string.IsNullOrEmpty(step.Action.Condition) && step.Action.Condition != "success()")
|
||||
{
|
||||
sb.AppendLine($" if: {step.Action.Condition}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Legend
|
||||
sb.AppendLine();
|
||||
sb.Append("Legend: \u2713 = completed, \u25B6 = current/paused");
|
||||
if (steps.Any(s => s.Change.HasValue))
|
||||
{
|
||||
sb.Append(", [ADDED] = new, [MODIFIED] = edited, [MOVED] = reordered");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step add run command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleAddRun(AddRunCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
// Create the ActionStep
|
||||
var actionStep = _factory.CreateRunStep(
|
||||
script: command.Script,
|
||||
name: command.Name,
|
||||
shell: command.Shell,
|
||||
workingDirectory: command.WorkingDirectory,
|
||||
env: command.Env,
|
||||
condition: command.Condition,
|
||||
continueOnError: command.ContinueOnError,
|
||||
timeoutMinutes: command.Timeout);
|
||||
|
||||
// Wrap in IActionRunner
|
||||
var runner = _factory.WrapInRunner(actionStep, jobContext);
|
||||
|
||||
// Insert into the queue
|
||||
var index = _manipulator.InsertStep(runner, command.Position);
|
||||
|
||||
var stepInfo = StepInfo.FromStep(runner, index, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
Trace.Info($"Added run step '{actionStep.DisplayName}' at position {index}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step added at position {index}: {actionStep.DisplayName}",
|
||||
Result = new
|
||||
{
|
||||
index,
|
||||
step = new
|
||||
{
|
||||
name = stepInfo.Name,
|
||||
type = stepInfo.Type,
|
||||
typeDetail = stepInfo.TypeDetail,
|
||||
status = stepInfo.Status.ToString().ToLower()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step add uses command with action download integration.
|
||||
/// Downloads the action via IActionManager and handles pre/post steps.
|
||||
/// </summary>
|
||||
private async Task<StepCommandResult> HandleAddUsesAsync(AddUsesCommand command, IExecutionContext jobContext)
|
||||
{
|
||||
// Create the ActionStep
|
||||
var actionStep = _factory.CreateUsesStep(
|
||||
actionReference: command.Action,
|
||||
name: command.Name,
|
||||
with: command.With,
|
||||
env: command.Env,
|
||||
condition: command.Condition,
|
||||
continueOnError: command.ContinueOnError,
|
||||
timeoutMinutes: command.Timeout);
|
||||
|
||||
// For local actions (starting with ./ or ..) and docker actions, skip download
|
||||
var isLocalOrDocker = command.Action.StartsWith("./") ||
|
||||
command.Action.StartsWith("../") ||
|
||||
command.Action.StartsWith("docker://", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
IActionRunner preStepRunner = null;
|
||||
string downloadMessage = null;
|
||||
|
||||
if (!isLocalOrDocker)
|
||||
{
|
||||
// Download the action via IActionManager
|
||||
try
|
||||
{
|
||||
var actionManager = HostContext.GetService<IActionManager>();
|
||||
|
||||
Trace.Info($"Preparing action '{command.Action}' for download...");
|
||||
|
||||
// PrepareActionsAsync downloads the action and returns info about pre/post steps
|
||||
var prepareResult = await actionManager.PrepareActionsAsync(
|
||||
jobContext,
|
||||
new[] { actionStep }
|
||||
);
|
||||
|
||||
// Check if this action has a pre-step
|
||||
if (prepareResult.PreStepTracker != null &&
|
||||
prepareResult.PreStepTracker.TryGetValue(actionStep.Id, out var preRunner))
|
||||
{
|
||||
preStepRunner = preRunner;
|
||||
Trace.Info($"Action '{command.Action}' has a pre-step that will be inserted");
|
||||
}
|
||||
|
||||
// Note: Post-steps are handled automatically by the job infrastructure
|
||||
// when the action definition declares a post step. The ActionRunner
|
||||
// will add it to PostJobSteps during execution.
|
||||
|
||||
downloadMessage = $"Action '{command.Action}' downloaded successfully";
|
||||
Trace.Info(downloadMessage);
|
||||
}
|
||||
catch (DistributedTask.WebApi.UnresolvableActionDownloadInfoException ex)
|
||||
{
|
||||
// Action not found or not accessible
|
||||
Trace.Error($"Failed to resolve action: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Failed to resolve action '{command.Action}': {ex.Message}");
|
||||
}
|
||||
catch (DistributedTask.WebApi.FailedToResolveActionDownloadInfoException ex)
|
||||
{
|
||||
// Network or other transient error
|
||||
Trace.Error($"Failed to download action: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Failed to download action '{command.Action}': {ex.Message}. Try again.");
|
||||
}
|
||||
catch (DistributedTask.WebApi.InvalidActionArchiveException ex)
|
||||
{
|
||||
// Corrupted archive
|
||||
Trace.Error($"Invalid action archive: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Action '{command.Action}' has an invalid archive: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("action.yml") ||
|
||||
ex.Message.Contains("action.yaml") ||
|
||||
ex.Message.Contains("Dockerfile"))
|
||||
{
|
||||
// Action exists but has no valid entry point
|
||||
Trace.Error($"Invalid action format: {ex.Message}");
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ActionDownloadFailed,
|
||||
$"Action '{command.Action}' is invalid: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadMessage = isLocalOrDocker && command.Action.StartsWith("docker://")
|
||||
? "Docker action - container will be pulled when step executes"
|
||||
: "Local action - no download needed";
|
||||
}
|
||||
|
||||
// Calculate insertion position
|
||||
// If there's a pre-step, we need to insert it before the main step
|
||||
var insertPosition = command.Position;
|
||||
int mainStepIndex;
|
||||
int? preStepIndex = null;
|
||||
|
||||
if (preStepRunner != null)
|
||||
{
|
||||
// Insert pre-step first
|
||||
preStepIndex = _manipulator.InsertStep(preStepRunner, insertPosition);
|
||||
|
||||
// Then insert main step after the pre-step
|
||||
mainStepIndex = _manipulator.InsertStep(
|
||||
_factory.WrapInRunner(actionStep, jobContext),
|
||||
StepPosition.After(preStepIndex.Value));
|
||||
|
||||
Trace.Info($"Added pre-step at position {preStepIndex} and main step at position {mainStepIndex}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pre-step, just insert the main step
|
||||
var runner = _factory.WrapInRunner(actionStep, jobContext);
|
||||
mainStepIndex = _manipulator.InsertStep(runner, insertPosition);
|
||||
}
|
||||
|
||||
var stepInfo = StepInfo.FromStep(_factory.WrapInRunner(actionStep, jobContext), mainStepIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
Trace.Info($"Added uses step '{actionStep.DisplayName}' at position {mainStepIndex}");
|
||||
|
||||
// Build result message
|
||||
var messageBuilder = new StringBuilder();
|
||||
messageBuilder.Append($"Step added at position {mainStepIndex}: {actionStep.DisplayName}");
|
||||
|
||||
if (preStepIndex.HasValue)
|
||||
{
|
||||
messageBuilder.Append($"\n Pre-step added at position {preStepIndex.Value}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(downloadMessage))
|
||||
{
|
||||
messageBuilder.Append($"\n ({downloadMessage})");
|
||||
}
|
||||
|
||||
// TODO: Before production release, add action restriction checks:
|
||||
// - Verify action is in organization's allowed list
|
||||
// - Check verified creator requirements
|
||||
// - Enforce enterprise policies
|
||||
// For now, allow all actions in prototype
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = messageBuilder.ToString(),
|
||||
Result = new
|
||||
{
|
||||
index = mainStepIndex,
|
||||
preStepIndex,
|
||||
hasPreStep = preStepIndex.HasValue,
|
||||
step = new
|
||||
{
|
||||
name = stepInfo.Name,
|
||||
type = stepInfo.Type,
|
||||
typeDetail = stepInfo.TypeDetail,
|
||||
status = stepInfo.Status.ToString().ToLower()
|
||||
},
|
||||
actionDownloaded = !isLocalOrDocker
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step edit command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleEdit(EditCommand command)
|
||||
{
|
||||
// Get the step info for validation
|
||||
var stepInfo = _manipulator.GetStep(command.Index);
|
||||
if (stepInfo == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"No step at index {command.Index}.");
|
||||
}
|
||||
|
||||
if (stepInfo.Status == StepStatus.Completed)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} has already completed and cannot be modified.");
|
||||
}
|
||||
|
||||
if (stepInfo.Status == StepStatus.Current)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} is currently executing. Use step-back first to modify it.");
|
||||
}
|
||||
|
||||
if (stepInfo.Action == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {command.Index} is not an action step and cannot be edited.");
|
||||
}
|
||||
|
||||
// Track what was changed for the message
|
||||
var changes = new List<string>();
|
||||
|
||||
// Apply edits
|
||||
_manipulator.EditStep(command.Index, action =>
|
||||
{
|
||||
// Name
|
||||
if (command.Name != null)
|
||||
{
|
||||
action.DisplayName = command.Name;
|
||||
changes.Add("name");
|
||||
}
|
||||
|
||||
// Condition
|
||||
if (command.Condition != null)
|
||||
{
|
||||
action.Condition = command.Condition;
|
||||
changes.Add("if");
|
||||
}
|
||||
|
||||
// Script (for run steps)
|
||||
if (command.Script != null)
|
||||
{
|
||||
UpdateScript(action, command.Script);
|
||||
changes.Add("script");
|
||||
}
|
||||
|
||||
// Shell (for run steps)
|
||||
if (command.Shell != null)
|
||||
{
|
||||
UpdateRunInput(action, "shell", command.Shell);
|
||||
changes.Add("shell");
|
||||
}
|
||||
|
||||
// Working directory
|
||||
if (command.WorkingDirectory != null)
|
||||
{
|
||||
UpdateRunInput(action, "workingDirectory", command.WorkingDirectory);
|
||||
changes.Add("working-directory");
|
||||
}
|
||||
|
||||
// With inputs (for uses steps)
|
||||
if (command.With != null)
|
||||
{
|
||||
foreach (var kvp in command.With)
|
||||
{
|
||||
UpdateWithInput(action, kvp.Key, kvp.Value);
|
||||
changes.Add($"with.{kvp.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove with inputs
|
||||
if (command.RemoveWith != null)
|
||||
{
|
||||
foreach (var key in command.RemoveWith)
|
||||
{
|
||||
RemoveInput(action, key);
|
||||
changes.Add($"remove with.{key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars
|
||||
if (command.Env != null)
|
||||
{
|
||||
foreach (var kvp in command.Env)
|
||||
{
|
||||
UpdateEnvVar(action, kvp.Key, kvp.Value);
|
||||
changes.Add($"env.{kvp.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove env vars
|
||||
if (command.RemoveEnv != null)
|
||||
{
|
||||
foreach (var key in command.RemoveEnv)
|
||||
{
|
||||
RemoveEnvVar(action, key);
|
||||
changes.Add($"remove env.{key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Continue on error
|
||||
if (command.ContinueOnError.HasValue)
|
||||
{
|
||||
action.ContinueOnError = new BooleanToken(null, null, null, command.ContinueOnError.Value);
|
||||
changes.Add("continue-on-error");
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (command.Timeout.HasValue)
|
||||
{
|
||||
action.TimeoutInMinutes = new NumberToken(null, null, null, command.Timeout.Value);
|
||||
changes.Add("timeout-minutes");
|
||||
}
|
||||
});
|
||||
|
||||
var changesStr = changes.Count > 0 ? string.Join(", ", changes) : "no changes";
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step {command.Index} updated ({changesStr})",
|
||||
Result = new
|
||||
{
|
||||
index = command.Index,
|
||||
changes
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step remove command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleRemove(RemoveCommand command)
|
||||
{
|
||||
// Get step info for the message
|
||||
var stepInfo = _manipulator.GetStep(command.Index);
|
||||
var stepName = stepInfo?.Name ?? $"step {command.Index}";
|
||||
|
||||
// Remove the step
|
||||
_manipulator.RemoveStep(command.Index);
|
||||
|
||||
Trace.Info($"Removed step '{stepName}' from position {command.Index}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step {command.Index} removed: {stepName}",
|
||||
Result = new
|
||||
{
|
||||
index = command.Index,
|
||||
removedStep = stepName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step move command.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleMove(MoveCommand command)
|
||||
{
|
||||
// Get step info for the message
|
||||
var stepInfo = _manipulator.GetStep(command.FromIndex);
|
||||
var stepName = stepInfo?.Name ?? $"step {command.FromIndex}";
|
||||
|
||||
// Move the step
|
||||
var newIndex = _manipulator.MoveStep(command.FromIndex, command.Position);
|
||||
|
||||
Trace.Info($"Moved step '{stepName}' from position {command.FromIndex} to {newIndex}");
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Step moved from position {command.FromIndex} to {newIndex}: {stepName}",
|
||||
Result = new
|
||||
{
|
||||
fromIndex = command.FromIndex,
|
||||
toIndex = newIndex,
|
||||
stepName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the !step export command.
|
||||
/// Generates YAML output for modified steps with optional change comments.
|
||||
/// </summary>
|
||||
private StepCommandResult HandleExport(ExportCommand command)
|
||||
{
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var changes = _manipulator.GetChanges();
|
||||
|
||||
IEnumerable<StepInfo> toExport;
|
||||
if (command.ChangesOnly)
|
||||
{
|
||||
toExport = steps.Where(s => s.Change.HasValue && s.Action != null);
|
||||
}
|
||||
else
|
||||
{
|
||||
toExport = steps.Where(s => s.Action != null);
|
||||
}
|
||||
|
||||
var yaml = _serializer.ToYaml(toExport, command.WithComments);
|
||||
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = yaml,
|
||||
Result = new
|
||||
{
|
||||
yaml,
|
||||
totalSteps = steps.Count,
|
||||
exportedSteps = toExport.Count(),
|
||||
addedCount = changes.Count(c => c.Type == ChangeType.Added),
|
||||
modifiedCount = changes.Count(c => c.Type == ChangeType.Modified),
|
||||
movedCount = changes.Count(c => c.Type == ChangeType.Moved),
|
||||
removedCount = changes.Count(c => c.Type == ChangeType.Removed)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that we have a valid context and manipulator.
|
||||
/// </summary>
|
||||
private void ValidateContext(IExecutionContext jobContext)
|
||||
{
|
||||
if (_manipulator == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"Step manipulator not initialized. Debug session may not be active.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the script in a run step's Inputs mapping.
|
||||
/// </summary>
|
||||
private void UpdateScript(ActionStep action, string script)
|
||||
{
|
||||
UpdateRunInput(action, "script", script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a value in a run step's Inputs mapping.
|
||||
/// </summary>
|
||||
private void UpdateRunInput(ActionStep action, string key, string value)
|
||||
{
|
||||
if (action.Reference is not ScriptReference)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
"Cannot update script/shell/working-directory on a non-run step.");
|
||||
}
|
||||
|
||||
if (action.Inputs == null)
|
||||
{
|
||||
action.Inputs = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Inputs as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a with input for a uses step.
|
||||
/// </summary>
|
||||
private void UpdateWithInput(ActionStep action, string key, string value)
|
||||
{
|
||||
// For uses steps, Inputs contains the "with" values
|
||||
if (action.Reference is ScriptReference)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
"Cannot update 'with' on a run step. Use --script instead.");
|
||||
}
|
||||
|
||||
if (action.Inputs == null)
|
||||
{
|
||||
action.Inputs = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Inputs as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an input from the step.
|
||||
/// </summary>
|
||||
private void RemoveInput(ActionStep action, string key)
|
||||
{
|
||||
RemoveMappingValue(action.Inputs as MappingToken, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an environment variable.
|
||||
/// </summary>
|
||||
private void UpdateEnvVar(ActionStep action, string key, string value)
|
||||
{
|
||||
if (action.Environment == null)
|
||||
{
|
||||
action.Environment = new MappingToken(null, null, null);
|
||||
}
|
||||
|
||||
UpdateMappingValue(action.Environment as MappingToken, key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an environment variable.
|
||||
/// </summary>
|
||||
private void RemoveEnvVar(ActionStep action, string key)
|
||||
{
|
||||
RemoveMappingValue(action.Environment as MappingToken, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates or adds a key-value pair in a MappingToken.
|
||||
/// </summary>
|
||||
private void UpdateMappingValue(MappingToken mapping, string key, string value)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and update existing key, or add new one
|
||||
for (int i = 0; i < mapping.Count; i++)
|
||||
{
|
||||
var pair = mapping[i];
|
||||
if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Found it - replace the value
|
||||
mapping.RemoveAt(i);
|
||||
mapping.Insert(i,
|
||||
new StringToken(null, null, null, key),
|
||||
new StringToken(null, null, null, value));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found - add new entry
|
||||
mapping.Add(
|
||||
new StringToken(null, null, null, key),
|
||||
new StringToken(null, null, null, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a key from a MappingToken.
|
||||
/// </summary>
|
||||
private void RemoveMappingValue(MappingToken mapping, string key)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mapping.Count; i++)
|
||||
{
|
||||
var pair = mapping[i];
|
||||
if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mapping.RemoveAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
930
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
930
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs
Normal file
@@ -0,0 +1,930 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Common;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for parsing step commands from REPL strings or JSON.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepCommandParser))]
|
||||
public interface IStepCommandParser : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a command string (REPL or JSON) into a structured StepCommand.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string (e.g., "!step list --verbose" or JSON)</param>
|
||||
/// <returns>Parsed StepCommand</returns>
|
||||
/// <exception cref="StepCommandException">If parsing fails</exception>
|
||||
StepCommand Parse(string input);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the input is a step command.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to check</param>
|
||||
/// <returns>True if this is a step command (REPL or JSON format)</returns>
|
||||
bool IsStepCommand(string input);
|
||||
}
|
||||
|
||||
#region Command Classes
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all step commands.
|
||||
/// </summary>
|
||||
public abstract class StepCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the original input was JSON (affects response format).
|
||||
/// </summary>
|
||||
public bool WasJsonInput { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step list [--verbose]
|
||||
/// </summary>
|
||||
public class ListCommand : StepCommand
|
||||
{
|
||||
public bool Verbose { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step add run "script" [options]
|
||||
/// </summary>
|
||||
public class AddRunCommand : StepCommand
|
||||
{
|
||||
public string Script { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step add uses "action@ref" [options]
|
||||
/// </summary>
|
||||
public class AddUsesCommand : StepCommand
|
||||
{
|
||||
public string Action { get; set; }
|
||||
public string Name { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
public StepPosition Position { get; set; } = StepPosition.Last();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step edit <index> [modifications]
|
||||
/// </summary>
|
||||
public class EditCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Script { get; set; }
|
||||
public string Action { get; set; }
|
||||
public string Shell { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public Dictionary<string, string> With { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; }
|
||||
public List<string> RemoveWith { get; set; }
|
||||
public List<string> RemoveEnv { get; set; }
|
||||
public bool? ContinueOnError { get; set; }
|
||||
public int? Timeout { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step remove <index>
|
||||
/// </summary>
|
||||
public class RemoveCommand : StepCommand
|
||||
{
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step move <from> [position options]
|
||||
/// </summary>
|
||||
public class MoveCommand : StepCommand
|
||||
{
|
||||
public int FromIndex { get; set; }
|
||||
public StepPosition Position { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// !step export [--changes-only] [--with-comments]
|
||||
/// </summary>
|
||||
public class ExportCommand : StepCommand
|
||||
{
|
||||
public bool ChangesOnly { get; set; }
|
||||
public bool WithComments { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Position Types
|
||||
|
||||
/// <summary>
|
||||
/// Types of position specifications for inserting/moving steps.
|
||||
/// </summary>
|
||||
public enum PositionType
|
||||
{
|
||||
/// <summary>Insert at specific index (1-based)</summary>
|
||||
At,
|
||||
/// <summary>Insert after specific index (1-based)</summary>
|
||||
After,
|
||||
/// <summary>Insert before specific index (1-based)</summary>
|
||||
Before,
|
||||
/// <summary>Insert at first pending position</summary>
|
||||
First,
|
||||
/// <summary>Insert at end (default)</summary>
|
||||
Last
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a position for inserting or moving steps.
|
||||
/// </summary>
|
||||
public class StepPosition
|
||||
{
|
||||
public PositionType Type { get; set; }
|
||||
public int? Index { get; set; }
|
||||
|
||||
public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index };
|
||||
public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index };
|
||||
public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index };
|
||||
public static StepPosition First() => new StepPosition { Type = PositionType.First };
|
||||
public static StepPosition Last() => new StepPosition { Type = PositionType.Last };
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Type switch
|
||||
{
|
||||
PositionType.At => $"at {Index}",
|
||||
PositionType.After => $"after {Index}",
|
||||
PositionType.Before => $"before {Index}",
|
||||
PositionType.First => "first",
|
||||
PositionType.Last => "last",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parser implementation for step commands (REPL and JSON formats).
|
||||
/// </summary>
|
||||
public sealed class StepCommandParser : RunnerService, IStepCommandParser
|
||||
{
|
||||
// Regex to match quoted strings (handles escaped quotes)
|
||||
private static readonly Regex QuotedStringRegex = new Regex(
|
||||
@"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public bool IsStepCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var trimmed = input.Trim();
|
||||
|
||||
// REPL command format: !step ...
|
||||
if (trimmed.StartsWith("!step", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// JSON format: {"cmd": "step.*", ...}
|
||||
if (trimmed.StartsWith("{") && trimmed.Contains("\"cmd\"") && trimmed.Contains("\"step."))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public StepCommand Parse(string input)
|
||||
{
|
||||
var trimmed = input?.Trim() ?? "";
|
||||
|
||||
if (trimmed.StartsWith("{"))
|
||||
{
|
||||
return ParseJsonCommand(trimmed);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ParseReplCommand(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Parsing
|
||||
|
||||
private StepCommand ParseJsonCommand(string json)
|
||||
{
|
||||
JObject obj;
|
||||
try
|
||||
{
|
||||
obj = JObject.Parse(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, $"Invalid JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
var cmd = obj["cmd"]?.ToString();
|
||||
if (string.IsNullOrEmpty(cmd))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'cmd' field in JSON");
|
||||
}
|
||||
|
||||
StepCommand result = cmd switch
|
||||
{
|
||||
"step.list" => ParseJsonListCommand(obj),
|
||||
"step.add" => ParseJsonAddCommand(obj),
|
||||
"step.edit" => ParseJsonEditCommand(obj),
|
||||
"step.remove" => ParseJsonRemoveCommand(obj),
|
||||
"step.move" => ParseJsonMoveCommand(obj),
|
||||
"step.export" => ParseJsonExportCommand(obj),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown command: {cmd}")
|
||||
};
|
||||
|
||||
result.WasJsonInput = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
private ListCommand ParseJsonListCommand(JObject obj)
|
||||
{
|
||||
return new ListCommand
|
||||
{
|
||||
Verbose = obj["verbose"]?.Value<bool>() ?? false
|
||||
};
|
||||
}
|
||||
|
||||
private StepCommand ParseJsonAddCommand(JObject obj)
|
||||
{
|
||||
var type = obj["type"]?.ToString()?.ToLower();
|
||||
|
||||
if (type == "run")
|
||||
{
|
||||
var script = obj["script"]?.ToString();
|
||||
if (string.IsNullOrEmpty(script))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'script' field for run step");
|
||||
}
|
||||
|
||||
return new AddRunCommand
|
||||
{
|
||||
Script = script,
|
||||
Name = obj["name"]?.ToString(),
|
||||
Shell = obj["shell"]?.ToString(),
|
||||
WorkingDirectory = obj["workingDirectory"]?.ToString(),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
|
||||
Timeout = obj["timeout"]?.Value<int>(),
|
||||
Position = ParseJsonPosition(obj["position"])
|
||||
};
|
||||
}
|
||||
else if (type == "uses")
|
||||
{
|
||||
var action = obj["action"]?.ToString();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'action' field for uses step");
|
||||
}
|
||||
|
||||
return new AddUsesCommand
|
||||
{
|
||||
Action = action,
|
||||
Name = obj["name"]?.ToString(),
|
||||
With = ParseJsonDictionary(obj["with"]),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
|
||||
Timeout = obj["timeout"]?.Value<int>(),
|
||||
Position = ParseJsonPosition(obj["position"])
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
|
||||
}
|
||||
}
|
||||
|
||||
private EditCommand ParseJsonEditCommand(JObject obj)
|
||||
{
|
||||
var index = obj["index"]?.Value<int>();
|
||||
if (!index.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for edit command");
|
||||
}
|
||||
|
||||
return new EditCommand
|
||||
{
|
||||
Index = index.Value,
|
||||
Name = obj["name"]?.ToString(),
|
||||
Script = obj["script"]?.ToString(),
|
||||
Action = obj["action"]?.ToString(),
|
||||
Shell = obj["shell"]?.ToString(),
|
||||
WorkingDirectory = obj["workingDirectory"]?.ToString(),
|
||||
Condition = obj["if"]?.ToString(),
|
||||
With = ParseJsonDictionary(obj["with"]),
|
||||
Env = ParseJsonDictionary(obj["env"]),
|
||||
RemoveWith = ParseJsonStringList(obj["removeWith"]),
|
||||
RemoveEnv = ParseJsonStringList(obj["removeEnv"]),
|
||||
ContinueOnError = obj["continueOnError"]?.Value<bool>(),
|
||||
Timeout = obj["timeout"]?.Value<int>()
|
||||
};
|
||||
}
|
||||
|
||||
private RemoveCommand ParseJsonRemoveCommand(JObject obj)
|
||||
{
|
||||
var index = obj["index"]?.Value<int>();
|
||||
if (!index.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for remove command");
|
||||
}
|
||||
|
||||
return new RemoveCommand { Index = index.Value };
|
||||
}
|
||||
|
||||
private MoveCommand ParseJsonMoveCommand(JObject obj)
|
||||
{
|
||||
var from = obj["from"]?.Value<int>();
|
||||
if (!from.HasValue)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'from' field for move command");
|
||||
}
|
||||
|
||||
var position = ParseJsonPosition(obj["position"]);
|
||||
if (position.Type == PositionType.Last)
|
||||
{
|
||||
// Default 'last' is fine for add, but move needs explicit position
|
||||
// unless explicitly set
|
||||
var posObj = obj["position"];
|
||||
if (posObj == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'position' field for move command");
|
||||
}
|
||||
}
|
||||
|
||||
return new MoveCommand
|
||||
{
|
||||
FromIndex = from.Value,
|
||||
Position = position
|
||||
};
|
||||
}
|
||||
|
||||
private ExportCommand ParseJsonExportCommand(JObject obj)
|
||||
{
|
||||
return new ExportCommand
|
||||
{
|
||||
ChangesOnly = obj["changesOnly"]?.Value<bool>() ?? false,
|
||||
WithComments = obj["withComments"]?.Value<bool>() ?? false
|
||||
};
|
||||
}
|
||||
|
||||
private StepPosition ParseJsonPosition(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return StepPosition.Last();
|
||||
|
||||
if (token.Type == JTokenType.Object)
|
||||
{
|
||||
var obj = (JObject)token;
|
||||
|
||||
if (obj["at"] != null)
|
||||
return StepPosition.At(obj["at"].Value<int>());
|
||||
if (obj["after"] != null)
|
||||
return StepPosition.After(obj["after"].Value<int>());
|
||||
if (obj["before"] != null)
|
||||
return StepPosition.Before(obj["before"].Value<int>());
|
||||
if (obj["first"]?.Value<bool>() == true)
|
||||
return StepPosition.First();
|
||||
if (obj["last"]?.Value<bool>() == true)
|
||||
return StepPosition.Last();
|
||||
}
|
||||
|
||||
return StepPosition.Last();
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ParseJsonDictionary(JToken token)
|
||||
{
|
||||
if (token == null || token.Type != JTokenType.Object)
|
||||
return null;
|
||||
|
||||
var result = new Dictionary<string, string>();
|
||||
foreach (var prop in ((JObject)token).Properties())
|
||||
{
|
||||
result[prop.Name] = prop.Value?.ToString() ?? "";
|
||||
}
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private List<string> ParseJsonStringList(JToken token)
|
||||
{
|
||||
if (token == null || token.Type != JTokenType.Array)
|
||||
return null;
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var item in (JArray)token)
|
||||
{
|
||||
var str = item?.ToString();
|
||||
if (!string.IsNullOrEmpty(str))
|
||||
result.Add(str);
|
||||
}
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region REPL Parsing
|
||||
|
||||
private StepCommand ParseReplCommand(string input)
|
||||
{
|
||||
// Tokenize the input, respecting quoted strings
|
||||
var tokens = Tokenize(input);
|
||||
|
||||
if (tokens.Count < 2 || !tokens[0].Equals("!step", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Invalid command format. Expected: !step <command> [args...]");
|
||||
}
|
||||
|
||||
var subCommand = tokens[1].ToLower();
|
||||
|
||||
return subCommand switch
|
||||
{
|
||||
"list" => ParseReplListCommand(tokens),
|
||||
"add" => ParseReplAddCommand(tokens),
|
||||
"edit" => ParseReplEditCommand(tokens),
|
||||
"remove" => ParseReplRemoveCommand(tokens),
|
||||
"move" => ParseReplMoveCommand(tokens),
|
||||
"export" => ParseReplExportCommand(tokens),
|
||||
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}")
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Tokenize(string input)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var remaining = input;
|
||||
|
||||
while (!string.IsNullOrEmpty(remaining))
|
||||
{
|
||||
remaining = remaining.TrimStart();
|
||||
if (string.IsNullOrEmpty(remaining))
|
||||
break;
|
||||
|
||||
// Check for quoted string
|
||||
var match = QuotedStringRegex.Match(remaining);
|
||||
if (match.Success && match.Index == 0)
|
||||
{
|
||||
// Extract the quoted content (without quotes)
|
||||
var quoted = match.Value;
|
||||
var content = quoted.Substring(1, quoted.Length - 2);
|
||||
// Unescape
|
||||
content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\");
|
||||
tokens.Add(content);
|
||||
remaining = remaining.Substring(match.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-quoted token
|
||||
var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' });
|
||||
if (spaceIndex == -1)
|
||||
{
|
||||
tokens.Add(remaining);
|
||||
break;
|
||||
}
|
||||
tokens.Add(remaining.Substring(0, spaceIndex));
|
||||
remaining = remaining.Substring(spaceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private ListCommand ParseReplListCommand(List<string> tokens)
|
||||
{
|
||||
var cmd = new ListCommand();
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var token = tokens[i].ToLower();
|
||||
if (token == "--verbose" || token == "-v")
|
||||
{
|
||||
cmd.Verbose = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option for list: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private StepCommand ParseReplAddCommand(List<string> tokens)
|
||||
{
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add <run|uses> <script|action> [options]");
|
||||
}
|
||||
|
||||
var type = tokens[2].ToLower();
|
||||
|
||||
if (type == "run")
|
||||
{
|
||||
return ParseReplAddRunCommand(tokens);
|
||||
}
|
||||
else if (type == "uses")
|
||||
{
|
||||
return ParseReplAddUsesCommand(tokens);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidType,
|
||||
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
|
||||
}
|
||||
}
|
||||
|
||||
private AddRunCommand ParseReplAddRunCommand(List<string> tokens)
|
||||
{
|
||||
// !step add run "script" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add run \"<script>\" [--name \"...\"] [--shell <shell>] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddRunCommand
|
||||
{
|
||||
Script = tokens[3],
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private AddUsesCommand ParseReplAddUsesCommand(List<string> tokens)
|
||||
{
|
||||
// !step add uses "action@ref" [options]
|
||||
if (tokens.Count < 4)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step add uses <action@ref> [--name \"...\"] [--with key=value] [--at|--after|--before <n>]");
|
||||
}
|
||||
|
||||
var cmd = new AddUsesCommand
|
||||
{
|
||||
Action = tokens[3],
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 4; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private EditCommand ParseReplEditCommand(List<string> tokens)
|
||||
{
|
||||
// !step edit <index> [modifications]
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step edit <index> [--name \"...\"] [--script \"...\"] [--if \"...\"]");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new EditCommand
|
||||
{
|
||||
Index = index,
|
||||
With = new Dictionary<string, string>(),
|
||||
Env = new Dictionary<string, string>(),
|
||||
RemoveWith = new List<string>(),
|
||||
RemoveEnv = new List<string>()
|
||||
};
|
||||
|
||||
// Parse options
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--name":
|
||||
cmd.Name = GetNextArg(tokens, ref i, "--name");
|
||||
break;
|
||||
case "--script":
|
||||
cmd.Script = GetNextArg(tokens, ref i, "--script");
|
||||
break;
|
||||
case "--action":
|
||||
cmd.Action = GetNextArg(tokens, ref i, "--action");
|
||||
break;
|
||||
case "--shell":
|
||||
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
|
||||
break;
|
||||
case "--working-directory":
|
||||
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
|
||||
break;
|
||||
case "--if":
|
||||
cmd.Condition = GetNextArg(tokens, ref i, "--if");
|
||||
break;
|
||||
case "--with":
|
||||
ParseKeyValueArg(tokens, ref i, cmd.With);
|
||||
break;
|
||||
case "--env":
|
||||
ParseEnvArg(tokens, ref i, cmd.Env);
|
||||
break;
|
||||
case "--remove-with":
|
||||
cmd.RemoveWith.Add(GetNextArg(tokens, ref i, "--remove-with"));
|
||||
break;
|
||||
case "--remove-env":
|
||||
cmd.RemoveEnv.Add(GetNextArg(tokens, ref i, "--remove-env"));
|
||||
break;
|
||||
case "--continue-on-error":
|
||||
cmd.ContinueOnError = true;
|
||||
break;
|
||||
case "--no-continue-on-error":
|
||||
cmd.ContinueOnError = false;
|
||||
break;
|
||||
case "--timeout":
|
||||
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty collections
|
||||
if (cmd.With.Count == 0)
|
||||
cmd.With = null;
|
||||
if (cmd.Env.Count == 0)
|
||||
cmd.Env = null;
|
||||
if (cmd.RemoveWith.Count == 0)
|
||||
cmd.RemoveWith = null;
|
||||
if (cmd.RemoveEnv.Count == 0)
|
||||
cmd.RemoveEnv = null;
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private RemoveCommand ParseReplRemoveCommand(List<string> tokens)
|
||||
{
|
||||
// !step remove <index>
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step remove <index>");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var index))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
return new RemoveCommand { Index = index };
|
||||
}
|
||||
|
||||
private MoveCommand ParseReplMoveCommand(List<string> tokens)
|
||||
{
|
||||
// !step move <from> --to|--after|--before <index>|--first|--last
|
||||
if (tokens.Count < 3)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Usage: !step move <from> --to|--after|--before <index>|--first|--last");
|
||||
}
|
||||
|
||||
if (!int.TryParse(tokens[2], out var fromIndex))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Invalid from index: {tokens[2]}. Must be a number.");
|
||||
}
|
||||
|
||||
var cmd = new MoveCommand { FromIndex = fromIndex };
|
||||
|
||||
// Parse position
|
||||
for (int i = 3; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--to":
|
||||
case "--at":
|
||||
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, opt));
|
||||
break;
|
||||
case "--after":
|
||||
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
|
||||
break;
|
||||
case "--before":
|
||||
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
|
||||
break;
|
||||
case "--first":
|
||||
cmd.Position = StepPosition.First();
|
||||
break;
|
||||
case "--last":
|
||||
cmd.Position = StepPosition.Last();
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.Position == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
"Move command requires a position (--to, --after, --before, --first, or --last)");
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private ExportCommand ParseReplExportCommand(List<string> tokens)
|
||||
{
|
||||
var cmd = new ExportCommand();
|
||||
|
||||
for (int i = 2; i < tokens.Count; i++)
|
||||
{
|
||||
var opt = tokens[i].ToLower();
|
||||
|
||||
switch (opt)
|
||||
{
|
||||
case "--changes-only":
|
||||
cmd.ChangesOnly = true;
|
||||
break;
|
||||
case "--with-comments":
|
||||
cmd.WithComments = true;
|
||||
break;
|
||||
default:
|
||||
throw new StepCommandException(StepCommandErrors.InvalidOption,
|
||||
$"Unknown option: {tokens[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
#region Argument Helpers
|
||||
|
||||
private string GetNextArg(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
if (index + 1 >= tokens.Count)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires a value");
|
||||
}
|
||||
return tokens[++index];
|
||||
}
|
||||
|
||||
private int GetNextArgInt(List<string> tokens, ref int index, string optName)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, optName);
|
||||
if (!int.TryParse(value, out var result))
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Option {optName} requires an integer value, got: {value}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ParseEnvArg(List<string> tokens, ref int index, Dictionary<string, string> env)
|
||||
{
|
||||
ParseKeyValueArg(tokens, ref index, env);
|
||||
}
|
||||
|
||||
private void ParseKeyValueArg(List<string> tokens, ref int index, Dictionary<string, string> dict)
|
||||
{
|
||||
var value = GetNextArg(tokens, ref index, "key=value");
|
||||
var eqIndex = value.IndexOf('=');
|
||||
if (eqIndex <= 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.ParseError,
|
||||
$"Expected key=value format, got: {value}");
|
||||
}
|
||||
var key = value.Substring(0, eqIndex);
|
||||
var val = value.Substring(eqIndex + 1);
|
||||
dict[key] = val;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
92
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
92
src/Runner.Worker/Dap/StepCommands/StepCommandResult.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Standardized result from step command execution.
|
||||
/// Used by both REPL and JSON API handlers to return consistent responses.
|
||||
/// </summary>
|
||||
public class StepCommandResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the command executed successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message describing the result (for REPL display).
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling (e.g., "INVALID_INDEX", "PARSE_ERROR").
|
||||
/// Null if successful.
|
||||
/// </summary>
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Command-specific result data (e.g., list of steps, step info, YAML export).
|
||||
/// Type varies by command - consumers should check Success before using.
|
||||
/// </summary>
|
||||
public object Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Ok(string message, object result = null)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = true,
|
||||
Message = message,
|
||||
Result = result
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static StepCommandResult Fail(string errorCode, string message)
|
||||
{
|
||||
return new StepCommandResult
|
||||
{
|
||||
Success = false,
|
||||
Error = errorCode,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for step commands.
|
||||
/// </summary>
|
||||
public static class StepCommandErrors
|
||||
{
|
||||
public const string InvalidIndex = "INVALID_INDEX";
|
||||
public const string InvalidCommand = "INVALID_COMMAND";
|
||||
public const string InvalidOption = "INVALID_OPTION";
|
||||
public const string InvalidType = "INVALID_TYPE";
|
||||
public const string ActionDownloadFailed = "ACTION_DOWNLOAD_FAILED";
|
||||
public const string ParseError = "PARSE_ERROR";
|
||||
public const string NotPaused = "NOT_PAUSED";
|
||||
public const string NoContext = "NO_CONTEXT";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown during step command parsing or execution.
|
||||
/// </summary>
|
||||
public class StepCommandException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public StepCommandException(string errorCode, string message) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public StepCommandResult ToResult()
|
||||
{
|
||||
return StepCommandResult.Fail(ErrorCode, Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
444
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
444
src/Runner.Worker/Dap/StepCommands/StepFactory.cs
Normal file
@@ -0,0 +1,444 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// Used by step commands to dynamically add steps during debug sessions.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepFactory))]
|
||||
public interface IStepFactory : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run step (script step).
|
||||
/// </summary>
|
||||
/// <param name="script">The script to execute</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="shell">Optional shell (bash, sh, pwsh, python, etc.)</param>
|
||||
/// <param name="workingDirectory">Optional working directory</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with ScriptReference</returns>
|
||||
ActionStep CreateRunStep(
|
||||
string script,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new uses step (action step).
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4", "owner/repo@ref", "./local-action")</param>
|
||||
/// <param name="name">Optional display name for the step</param>
|
||||
/// <param name="with">Optional input parameters for the action</param>
|
||||
/// <param name="env">Optional environment variables</param>
|
||||
/// <param name="condition">Optional condition expression (defaults to "success()")</param>
|
||||
/// <param name="continueOnError">Whether to continue on error (defaults to false)</param>
|
||||
/// <param name="timeoutMinutes">Optional timeout in minutes</param>
|
||||
/// <returns>A configured ActionStep with RepositoryPathReference or ContainerRegistryReference</returns>
|
||||
ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an ActionStep in an IActionRunner for execution.
|
||||
/// </summary>
|
||||
/// <param name="step">The ActionStep to wrap</param>
|
||||
/// <param name="jobContext">The job execution context</param>
|
||||
/// <param name="stage">The execution stage (Main, Pre, or Post)</param>
|
||||
/// <returns>An IActionRunner ready for execution</returns>
|
||||
IActionRunner WrapInRunner(
|
||||
ActionStep step,
|
||||
IExecutionContext jobContext,
|
||||
ActionRunStage stage = ActionRunStage.Main);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of an action reference string.
|
||||
/// </summary>
|
||||
public class ParsedActionReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of action reference.
|
||||
/// </summary>
|
||||
public ActionReferenceType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: "owner/repo". For local: null. For docker: null.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For GitHub actions: the git ref (tag/branch/commit). For local/docker: null.
|
||||
/// </summary>
|
||||
public string Ref { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For actions in subdirectories: the path within the repo. For local: the full path.
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For docker actions: the image reference.
|
||||
/// </summary>
|
||||
public string Image { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of action references.
|
||||
/// </summary>
|
||||
public enum ActionReferenceType
|
||||
{
|
||||
/// <summary>GitHub repository action (e.g., "actions/checkout@v4")</summary>
|
||||
Repository,
|
||||
/// <summary>Local action (e.g., "./.github/actions/my-action")</summary>
|
||||
Local,
|
||||
/// <summary>Docker container action (e.g., "docker://alpine:latest")</summary>
|
||||
Docker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating ActionStep and IActionRunner objects at runtime.
|
||||
/// </summary>
|
||||
public sealed class StepFactory : RunnerService, IStepFactory
|
||||
{
|
||||
// Constants for script step inputs (matching PipelineConstants.ScriptStepInputs)
|
||||
private const string ScriptInputKey = "script";
|
||||
private const string ShellInputKey = "shell";
|
||||
private const string WorkingDirectoryInputKey = "workingDirectory";
|
||||
|
||||
// Regex for parsing action references
|
||||
// Matches: owner/repo@ref, owner/repo/path@ref, owner/repo@ref/path (unusual but valid)
|
||||
private static readonly Regex ActionRefRegex = new Regex(
|
||||
@"^(?<name>[^/@]+/[^/@]+)(?:/(?<path>[^@]+))?@(?<ref>.+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateRunStep(
|
||||
string script,
|
||||
string name = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(script))
|
||||
{
|
||||
throw new ArgumentException("Script cannot be null or empty", nameof(script));
|
||||
}
|
||||
|
||||
var stepId = Guid.NewGuid();
|
||||
var step = new ActionStep
|
||||
{
|
||||
Id = stepId,
|
||||
Name = $"_dynamic_{stepId:N}",
|
||||
DisplayName = name ?? "Run script",
|
||||
Reference = new ScriptReference(),
|
||||
Condition = condition ?? "success()",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Build Inputs mapping with script, shell, working-directory
|
||||
step.Inputs = CreateRunInputs(script, shell, workingDirectory);
|
||||
|
||||
// Build Environment mapping
|
||||
if (env?.Count > 0)
|
||||
{
|
||||
step.Environment = CreateEnvToken(env);
|
||||
}
|
||||
|
||||
// Set continue-on-error
|
||||
if (continueOnError)
|
||||
{
|
||||
step.ContinueOnError = new BooleanToken(null, null, null, true);
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
if (timeoutMinutes.HasValue)
|
||||
{
|
||||
step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ActionStep CreateUsesStep(
|
||||
string actionReference,
|
||||
string name = null,
|
||||
Dictionary<string, string> with = null,
|
||||
Dictionary<string, string> env = null,
|
||||
string condition = null,
|
||||
bool continueOnError = false,
|
||||
int? timeoutMinutes = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(actionReference))
|
||||
{
|
||||
throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference));
|
||||
}
|
||||
|
||||
var parsed = ParseActionReference(actionReference);
|
||||
var stepId = Guid.NewGuid();
|
||||
|
||||
var step = new ActionStep
|
||||
{
|
||||
Id = stepId,
|
||||
Name = $"_dynamic_{stepId:N}",
|
||||
DisplayName = name ?? actionReference,
|
||||
Condition = condition ?? "success()",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Set reference based on action type
|
||||
switch (parsed.Type)
|
||||
{
|
||||
case ActionReferenceType.Repository:
|
||||
step.Reference = new RepositoryPathReference
|
||||
{
|
||||
Name = parsed.Name,
|
||||
Ref = parsed.Ref,
|
||||
Path = parsed.Path,
|
||||
RepositoryType = "GitHub"
|
||||
};
|
||||
break;
|
||||
|
||||
case ActionReferenceType.Local:
|
||||
step.Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = PipelineConstants.SelfAlias,
|
||||
Path = parsed.Path
|
||||
};
|
||||
break;
|
||||
|
||||
case ActionReferenceType.Docker:
|
||||
step.Reference = new ContainerRegistryReference
|
||||
{
|
||||
Image = parsed.Image
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Build with inputs
|
||||
if (with?.Count > 0)
|
||||
{
|
||||
step.Inputs = CreateWithInputs(with);
|
||||
}
|
||||
|
||||
// Build Environment mapping
|
||||
if (env?.Count > 0)
|
||||
{
|
||||
step.Environment = CreateEnvToken(env);
|
||||
}
|
||||
|
||||
// Set continue-on-error
|
||||
if (continueOnError)
|
||||
{
|
||||
step.ContinueOnError = new BooleanToken(null, null, null, true);
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
if (timeoutMinutes.HasValue)
|
||||
{
|
||||
step.TimeoutInMinutes = new NumberToken(null, null, null, timeoutMinutes.Value);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IActionRunner WrapInRunner(
|
||||
ActionStep step,
|
||||
IExecutionContext jobContext,
|
||||
ActionRunStage stage = ActionRunStage.Main)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(step));
|
||||
}
|
||||
if (jobContext == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(jobContext));
|
||||
}
|
||||
|
||||
var runner = HostContext.CreateService<IActionRunner>();
|
||||
runner.Action = step;
|
||||
runner.Stage = stage;
|
||||
runner.Condition = step.Condition;
|
||||
|
||||
// Create a child execution context for this step
|
||||
// The child context gets its own scope for outputs, logging, etc.
|
||||
// Following the pattern from JobExtension.cs line ~401
|
||||
runner.ExecutionContext = jobContext.CreateChild(
|
||||
recordId: step.Id,
|
||||
displayName: step.DisplayName,
|
||||
refName: step.Name,
|
||||
scopeName: null,
|
||||
contextName: step.ContextName,
|
||||
stage: stage
|
||||
);
|
||||
|
||||
return runner;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses an action reference string into its components.
|
||||
/// </summary>
|
||||
/// <param name="actionReference">The action reference (e.g., "actions/checkout@v4")</param>
|
||||
/// <returns>Parsed action reference components</returns>
|
||||
public static ParsedActionReference ParseActionReference(string actionReference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actionReference))
|
||||
{
|
||||
throw new ArgumentException("Action reference cannot be null or empty", nameof(actionReference));
|
||||
}
|
||||
|
||||
var trimmed = actionReference.Trim();
|
||||
|
||||
// Check for docker action: docker://image:tag
|
||||
if (trimmed.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Docker,
|
||||
Image = trimmed.Substring("docker://".Length)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for local action: ./ or ../ prefix
|
||||
if (trimmed.StartsWith("./") || trimmed.StartsWith("../"))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Local,
|
||||
Path = trimmed
|
||||
};
|
||||
}
|
||||
|
||||
// Parse as GitHub repository action: owner/repo@ref or owner/repo/path@ref
|
||||
var match = ActionRefRegex.Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var result = new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Repository,
|
||||
Name = match.Groups["name"].Value,
|
||||
Ref = match.Groups["ref"].Value
|
||||
};
|
||||
|
||||
if (match.Groups["path"].Success && !string.IsNullOrEmpty(match.Groups["path"].Value))
|
||||
{
|
||||
result.Path = match.Groups["path"].Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If no @ sign, assume it's a local action path
|
||||
if (!trimmed.Contains("@"))
|
||||
{
|
||||
return new ParsedActionReference
|
||||
{
|
||||
Type = ActionReferenceType.Local,
|
||||
Path = trimmed
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid format
|
||||
throw new StepCommandException(
|
||||
StepCommandErrors.ParseError,
|
||||
$"Invalid action reference format: '{actionReference}'. Expected: 'owner/repo@ref', './local-path', or 'docker://image:tag'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for run step inputs (script, shell, working-directory).
|
||||
/// </summary>
|
||||
private MappingToken CreateRunInputs(string script, string shell, string workingDirectory)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
|
||||
// Script is always required
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, ScriptInputKey),
|
||||
new StringToken(null, null, null, script)
|
||||
);
|
||||
|
||||
// Shell is optional
|
||||
if (!string.IsNullOrEmpty(shell))
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, ShellInputKey),
|
||||
new StringToken(null, null, null, shell)
|
||||
);
|
||||
}
|
||||
|
||||
// Working directory is optional
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, WorkingDirectoryInputKey),
|
||||
new StringToken(null, null, null, workingDirectory)
|
||||
);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for action "with" inputs.
|
||||
/// </summary>
|
||||
private MappingToken CreateWithInputs(Dictionary<string, string> with)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
|
||||
foreach (var kvp in with)
|
||||
{
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, kvp.Key),
|
||||
new StringToken(null, null, null, kvp.Value ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MappingToken for environment variables.
|
||||
/// </summary>
|
||||
private MappingToken CreateEnvToken(Dictionary<string, string> env)
|
||||
{
|
||||
var envMapping = new MappingToken(null, null, null);
|
||||
|
||||
foreach (var kvp in env)
|
||||
{
|
||||
envMapping.Add(
|
||||
new StringToken(null, null, null, kvp.Key),
|
||||
new StringToken(null, null, null, kvp.Value ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
return envMapping;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
215
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
215
src/Runner.Worker/Dap/StepCommands/StepInfo.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Step status for display and manipulation.
|
||||
/// </summary>
|
||||
public enum StepStatus
|
||||
{
|
||||
/// <summary>Step has completed execution.</summary>
|
||||
Completed,
|
||||
/// <summary>Step is currently executing or paused.</summary>
|
||||
Current,
|
||||
/// <summary>Step is pending execution.</summary>
|
||||
Pending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of change applied to a step.
|
||||
/// </summary>
|
||||
public enum ChangeType
|
||||
{
|
||||
/// <summary>Step was added during debug session.</summary>
|
||||
Added,
|
||||
/// <summary>Step was modified during debug session.</summary>
|
||||
Modified,
|
||||
/// <summary>Step was removed during debug session.</summary>
|
||||
Removed,
|
||||
/// <summary>Step was moved during debug session.</summary>
|
||||
Moved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified step information for display, manipulation, and serialization.
|
||||
/// Wraps both the underlying ActionStep and IStep with metadata about status and changes.
|
||||
/// </summary>
|
||||
public class StepInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 1-based index of the step in the combined list (completed + current + pending).
|
||||
/// </summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the step.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Step type: "run" or "uses"
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type detail: action reference for uses steps, script preview for run steps.
|
||||
/// </summary>
|
||||
public string TypeDetail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current execution status of the step.
|
||||
/// </summary>
|
||||
public StepStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type if the step was modified during this debug session, null otherwise.
|
||||
/// </summary>
|
||||
public ChangeType? Change { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying ActionStep (for serialization and modification).
|
||||
/// May be null for non-action steps (e.g., JobExtensionRunner).
|
||||
/// </summary>
|
||||
public ActionStep Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying IStep (for execution).
|
||||
/// </summary>
|
||||
public IStep Step { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original index before any moves (for change tracking).
|
||||
/// Only set when Change == Moved.
|
||||
/// </summary>
|
||||
public int? OriginalIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an IStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromStep(IStep step, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = step.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Step = step
|
||||
};
|
||||
|
||||
// Try to extract ActionStep from IActionRunner
|
||||
if (step is IActionRunner actionRunner && actionRunner.Action != null)
|
||||
{
|
||||
info.Action = actionRunner.Action;
|
||||
PopulateFromActionStep(info, actionRunner.Action);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-action step (e.g., JobExtensionRunner)
|
||||
info.Type = "extension";
|
||||
info.TypeDetail = step.GetType().Name;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a StepInfo from an ActionStep.
|
||||
/// </summary>
|
||||
public static StepInfo FromActionStep(ActionStep action, int index, StepStatus status)
|
||||
{
|
||||
var info = new StepInfo
|
||||
{
|
||||
Index = index,
|
||||
Name = action.DisplayName ?? "Unknown step",
|
||||
Status = status,
|
||||
Action = action
|
||||
};
|
||||
|
||||
PopulateFromActionStep(info, action);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates type information from an ActionStep.
|
||||
/// </summary>
|
||||
private static void PopulateFromActionStep(StepInfo info, ActionStep action)
|
||||
{
|
||||
switch (action.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
info.Type = "run";
|
||||
info.TypeDetail = GetScriptPreview(action);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = BuildUsesReference(repoRef);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
info.Type = "uses";
|
||||
info.TypeDetail = $"docker://{containerRef.Image}";
|
||||
break;
|
||||
|
||||
default:
|
||||
info.Type = "unknown";
|
||||
info.TypeDetail = action.Reference?.GetType().Name ?? "null";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a preview of the script (first line, truncated).
|
||||
/// </summary>
|
||||
private static string GetScriptPreview(ActionStep action)
|
||||
{
|
||||
if (action.Inputs is GitHub.DistributedTask.ObjectTemplating.Tokens.MappingToken mapping)
|
||||
{
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString();
|
||||
if (string.Equals(key, "script", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var script = pair.Value?.ToString() ?? "";
|
||||
// Get first line, truncate if too long
|
||||
var firstLine = script.Split('\n')[0].Trim();
|
||||
if (firstLine.Length > 40)
|
||||
{
|
||||
return firstLine.Substring(0, 37) + "...";
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "(script)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a uses reference string from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private static string BuildUsesReference(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action
|
||||
if (string.Equals(repoRef.RepositoryType, "self", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
}
|
||||
}
|
||||
642
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
642
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs
Normal file
@@ -0,0 +1,642 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for manipulating job steps during a debug session.
|
||||
/// Provides query and mutation operations on the step queue with change tracking.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepManipulator))]
|
||||
public interface IStepManipulator : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialize the manipulator with job context and current execution state.
|
||||
/// Must be called before any other operations.
|
||||
/// </summary>
|
||||
/// <param name="jobContext">The job execution context containing the step queues.</param>
|
||||
/// <param name="currentStepIndex">The 1-based index of the currently executing step, or 0 if no step is executing.</param>
|
||||
void Initialize(IExecutionContext jobContext, int currentStepIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the current step index (called as steps complete).
|
||||
/// </summary>
|
||||
/// <param name="index">The new 1-based index of the current step.</param>
|
||||
void UpdateCurrentIndex(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a completed step to the history (for step-back support).
|
||||
/// </summary>
|
||||
/// <param name="step">The step that completed.</param>
|
||||
void AddCompletedStep(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step that is executing/paused (if any).
|
||||
/// </summary>
|
||||
IStep CurrentStep { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all steps (completed + current + pending) as a unified list.
|
||||
/// </summary>
|
||||
/// <returns>List of StepInfo ordered by index (1-based).</returns>
|
||||
IReadOnlyList<StepInfo> GetAllSteps();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific step by 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based index of the step.</param>
|
||||
/// <returns>The StepInfo, or null if index is out of range.</returns>
|
||||
StepInfo GetStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of pending steps (not yet executed).
|
||||
/// </summary>
|
||||
int GetPendingCount();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 1-based index of the first pending step.
|
||||
/// </summary>
|
||||
/// <returns>The first pending index, or -1 if no pending steps.</returns>
|
||||
int GetFirstPendingIndex();
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a step at the specified position.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to insert.</param>
|
||||
/// <param name="position">The position specification (At, After, Before, First, Last).</param>
|
||||
/// <returns>The 1-based index where the step was inserted.</returns>
|
||||
/// <exception cref="StepCommandException">If the position is invalid.</exception>
|
||||
int InsertStep(IStep step, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a step at the specified 1-based index.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to remove.</param>
|
||||
/// <exception cref="StepCommandException">If the index is invalid or step cannot be removed.</exception>
|
||||
void RemoveStep(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a step from one position to another.
|
||||
/// </summary>
|
||||
/// <param name="fromIndex">The 1-based index of the step to move.</param>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <returns>The new 1-based index of the step.</returns>
|
||||
/// <exception cref="StepCommandException">If the move is invalid.</exception>
|
||||
int MoveStep(int fromIndex, StepPosition position);
|
||||
|
||||
/// <summary>
|
||||
/// Applies an edit to a step's ActionStep.
|
||||
/// </summary>
|
||||
/// <param name="index">The 1-based index of the step to edit.</param>
|
||||
/// <param name="edit">The edit action to apply.</param>
|
||||
/// <exception cref="StepCommandException">If the step cannot be edited.</exception>
|
||||
void EditStep(int index, Action<ActionStep> edit);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all changes made during this session.
|
||||
/// </summary>
|
||||
/// <returns>List of change records in chronological order.</returns>
|
||||
IReadOnlyList<StepChange> GetChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Records the original state for change tracking.
|
||||
/// Should be called when the debug session starts.
|
||||
/// </summary>
|
||||
void RecordOriginalState();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded changes (for testing or reset).
|
||||
/// </summary>
|
||||
void ClearChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of step manipulation operations.
|
||||
/// Manages the job step queue and tracks all modifications for export.
|
||||
/// </summary>
|
||||
public sealed class StepManipulator : RunnerService, IStepManipulator
|
||||
{
|
||||
private IExecutionContext _jobContext;
|
||||
private int _currentStepIndex;
|
||||
private IStep _currentStep;
|
||||
|
||||
// Completed steps (for display and step-back)
|
||||
private readonly List<IStep> _completedSteps = new List<IStep>();
|
||||
|
||||
// Original state for change tracking
|
||||
private List<StepInfo> _originalSteps;
|
||||
|
||||
// Change history
|
||||
private readonly List<StepChange> _changes = new List<StepChange>();
|
||||
|
||||
// Track which steps have been modified (by step Name/Id)
|
||||
private readonly HashSet<Guid> _modifiedStepIds = new HashSet<Guid>();
|
||||
private readonly HashSet<Guid> _addedStepIds = new HashSet<Guid>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStep CurrentStep => _currentStep;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialize(IExecutionContext jobContext, int currentStepIndex)
|
||||
{
|
||||
ArgUtil.NotNull(jobContext, nameof(jobContext));
|
||||
|
||||
_jobContext = jobContext;
|
||||
_currentStepIndex = currentStepIndex;
|
||||
_currentStep = null;
|
||||
_completedSteps.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateCurrentIndex(int index)
|
||||
{
|
||||
_currentStepIndex = index;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddCompletedStep(IStep step)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
_completedSteps.Add(step);
|
||||
_currentStep = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current step (the one that is executing/paused).
|
||||
/// </summary>
|
||||
public void SetCurrentStep(IStep step)
|
||||
{
|
||||
_currentStep = step;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepInfo> GetAllSteps()
|
||||
{
|
||||
var result = new List<StepInfo>();
|
||||
int index = 1;
|
||||
|
||||
// Add completed steps
|
||||
foreach (var step in _completedSteps)
|
||||
{
|
||||
var info = StepInfo.FromStep(step, index, StepStatus.Completed);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add current step if present
|
||||
if (_currentStep != null)
|
||||
{
|
||||
var info = StepInfo.FromStep(_currentStep, index, StepStatus.Current);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Add pending steps from queue
|
||||
if (_jobContext?.JobSteps != null)
|
||||
{
|
||||
foreach (var step in _jobContext.JobSteps)
|
||||
{
|
||||
var info = StepInfo.FromStep(step, index, StepStatus.Pending);
|
||||
ApplyChangeInfo(info);
|
||||
result.Add(info);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StepInfo GetStep(int index)
|
||||
{
|
||||
if (index < 1)
|
||||
return null;
|
||||
|
||||
var allSteps = GetAllSteps();
|
||||
if (index > allSteps.Count)
|
||||
return null;
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetPendingCount()
|
||||
{
|
||||
return _jobContext?.JobSteps?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetFirstPendingIndex()
|
||||
{
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
if (pendingCount == 0)
|
||||
return -1;
|
||||
|
||||
return completedCount + currentCount + 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int InsertStep(IStep step, StepPosition position)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
ValidateInitialized();
|
||||
|
||||
// Calculate the insertion index within the pending queue (0-based)
|
||||
int insertAt = CalculateInsertIndex(position);
|
||||
|
||||
// Convert queue to list for manipulation
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Insert the step
|
||||
pending.Insert(insertAt, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate the 1-based index in the overall step list
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var newIndex = firstPendingIndex + insertAt;
|
||||
|
||||
// Track the change
|
||||
var stepInfo = StepInfo.FromStep(step, newIndex, StepStatus.Pending);
|
||||
stepInfo.Change = ChangeType.Added;
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_addedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
_changes.Add(StepChange.Added(stepInfo, newIndex));
|
||||
|
||||
Trace.Info($"Inserted step '{step.DisplayName}' at position {newIndex} (queue index {insertAt})");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveStep(int index)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Calculate queue index (0-based)
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = index - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove the step
|
||||
var removedStep = pending[queueIndex];
|
||||
pending.RemoveAt(queueIndex);
|
||||
|
||||
// Re-queue remaining steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var removedInfo = StepInfo.FromStep(removedStep, index, StepStatus.Pending);
|
||||
removedInfo.Change = ChangeType.Removed;
|
||||
_changes.Add(StepChange.Removed(removedInfo));
|
||||
|
||||
Trace.Info($"Removed step '{removedStep.DisplayName}' from position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int MoveStep(int fromIndex, StepPosition position)
|
||||
{
|
||||
ValidateInitialized();
|
||||
var stepInfo = ValidatePendingIndex(fromIndex);
|
||||
|
||||
// Calculate queue indices - BEFORE modifying the queue
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var fromQueueIndex = fromIndex - firstPendingIndex;
|
||||
|
||||
// Convert queue to list
|
||||
var pending = _jobContext.JobSteps.ToList();
|
||||
_jobContext.JobSteps.Clear();
|
||||
|
||||
// Remove from original position
|
||||
var step = pending[fromQueueIndex];
|
||||
pending.RemoveAt(fromQueueIndex);
|
||||
|
||||
// Calculate new position (within the now-smaller list)
|
||||
// Pass firstPendingIndex since the queue is now cleared
|
||||
var toQueueIndex = CalculateMoveTargetIndex(position, pending.Count, fromQueueIndex, firstPendingIndex);
|
||||
|
||||
// Insert at new position
|
||||
pending.Insert(toQueueIndex, step);
|
||||
|
||||
// Re-queue all steps
|
||||
foreach (var s in pending)
|
||||
{
|
||||
_jobContext.JobSteps.Enqueue(s);
|
||||
}
|
||||
|
||||
// Calculate new 1-based index
|
||||
var newIndex = firstPendingIndex + toQueueIndex;
|
||||
|
||||
// Track the change
|
||||
var originalInfo = StepInfo.FromStep(step, fromIndex, StepStatus.Pending);
|
||||
_changes.Add(StepChange.Moved(originalInfo, newIndex));
|
||||
|
||||
if (step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
}
|
||||
|
||||
Trace.Info($"Moved step '{step.DisplayName}' from position {fromIndex} to {newIndex}");
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EditStep(int index, Action<ActionStep> edit)
|
||||
{
|
||||
ArgUtil.NotNull(edit, nameof(edit));
|
||||
ValidateInitialized();
|
||||
|
||||
var stepInfo = ValidatePendingIndex(index);
|
||||
|
||||
// Get the IActionRunner to access the ActionStep
|
||||
if (stepInfo.Step is not IActionRunner runner || runner.Action == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step at index {index} is not an action step and cannot be edited.");
|
||||
}
|
||||
|
||||
// Capture original state for change tracking
|
||||
var originalInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
|
||||
// Apply the edit
|
||||
edit(runner.Action);
|
||||
|
||||
// Update display name if it changed
|
||||
if (runner.Action.DisplayName != originalInfo.Name)
|
||||
{
|
||||
stepInfo.Name = runner.Action.DisplayName;
|
||||
}
|
||||
|
||||
// Track the change
|
||||
var modifiedInfo = StepInfo.FromStep(stepInfo.Step, index, StepStatus.Pending);
|
||||
modifiedInfo.Change = ChangeType.Modified;
|
||||
_modifiedStepIds.Add(runner.Action.Id);
|
||||
|
||||
_changes.Add(StepChange.Modified(originalInfo, modifiedInfo));
|
||||
|
||||
Trace.Info($"Edited step '{runner.Action.DisplayName}' at position {index}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<StepChange> GetChanges()
|
||||
{
|
||||
return _changes.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordOriginalState()
|
||||
{
|
||||
_originalSteps = GetAllSteps().ToList();
|
||||
Trace.Info($"Recorded original state: {_originalSteps.Count} steps");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearChanges()
|
||||
{
|
||||
_changes.Clear();
|
||||
_modifiedStepIds.Clear();
|
||||
_addedStepIds.Clear();
|
||||
_originalSteps = null;
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the manipulator has been initialized.
|
||||
/// </summary>
|
||||
private void ValidateInitialized()
|
||||
{
|
||||
if (_jobContext == null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.NoContext,
|
||||
"StepManipulator has not been initialized. Call Initialize() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the given index refers to a pending step that can be manipulated.
|
||||
/// </summary>
|
||||
/// <param name="index">1-based step index</param>
|
||||
/// <returns>The StepInfo at that index</returns>
|
||||
private StepInfo ValidatePendingIndex(int index)
|
||||
{
|
||||
var allSteps = GetAllSteps();
|
||||
var totalCount = allSteps.Count;
|
||||
var completedCount = _completedSteps.Count;
|
||||
var currentCount = _currentStep != null ? 1 : 0;
|
||||
var firstPendingIndex = completedCount + currentCount + 1;
|
||||
|
||||
if (index < 1 || index > totalCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Index {index} is out of range. Valid range: 1 to {totalCount}.");
|
||||
}
|
||||
|
||||
if (index <= completedCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} has already completed and cannot be modified.");
|
||||
}
|
||||
|
||||
if (index == completedCount + 1 && _currentStep != null)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Step {index} is currently executing. Use step-back first to modify it.");
|
||||
}
|
||||
|
||||
return allSteps[index - 1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the 0-based index within the pending queue for insertion.
|
||||
/// </summary>
|
||||
private int CalculateInsertIndex(StepPosition position)
|
||||
{
|
||||
var pendingCount = GetPendingCount();
|
||||
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return pendingCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
// Position.Index is 1-based overall index
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var queueIndex = position.Index.Value - firstPendingIndex;
|
||||
|
||||
if (queueIndex < 0)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - that is before the first pending step.");
|
||||
}
|
||||
if (queueIndex > pendingCount)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert at position {position.Index} - only {pendingCount} pending steps.");
|
||||
}
|
||||
return queueIndex;
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
|
||||
// If "after" points to a completed/current step, insert at beginning of pending
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate queue index (after means +1)
|
||||
var queueIndex = afterOverallIndex - firstPendingIndex + 1;
|
||||
return Math.Min(queueIndex, pendingCount);
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var firstPendingIndex = GetFirstPendingIndex();
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
|
||||
// If "before" points to a completed/current step, that's an error
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot insert before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
|
||||
// Calculate queue index
|
||||
var queueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
return Math.Max(0, queueIndex);
|
||||
}
|
||||
|
||||
default:
|
||||
return pendingCount; // Default to last
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the target index for a move operation.
|
||||
/// Note: This is called AFTER the item has been removed from the list,
|
||||
/// so we need to adjust indices that were after the removed item.
|
||||
/// </summary>
|
||||
/// <param name="position">The target position specification.</param>
|
||||
/// <param name="listCount">The count of items in the list after removal.</param>
|
||||
/// <param name="fromQueueIndex">The original queue index of the removed item.</param>
|
||||
/// <param name="firstPendingIndex">The first pending index (captured before queue was modified).</param>
|
||||
private int CalculateMoveTargetIndex(StepPosition position, int listCount, int fromQueueIndex, int firstPendingIndex)
|
||||
{
|
||||
switch (position.Type)
|
||||
{
|
||||
case PositionType.Last:
|
||||
return listCount;
|
||||
|
||||
case PositionType.First:
|
||||
return 0;
|
||||
|
||||
case PositionType.At:
|
||||
{
|
||||
var targetQueueIndex = position.Index.Value - firstPendingIndex;
|
||||
// Adjust for the fact that we removed the item first
|
||||
// Items that were after the removed item have shifted down by 1
|
||||
if (targetQueueIndex > fromQueueIndex)
|
||||
targetQueueIndex--;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.After:
|
||||
{
|
||||
var afterOverallIndex = position.Index.Value;
|
||||
if (afterOverallIndex < firstPendingIndex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// Convert to queue index
|
||||
var afterQueueIndex = afterOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (afterQueueIndex > fromQueueIndex)
|
||||
afterQueueIndex--;
|
||||
// Insert after that position (so +1)
|
||||
var targetQueueIndex = afterQueueIndex + 1;
|
||||
return Math.Max(0, Math.Min(targetQueueIndex, listCount));
|
||||
}
|
||||
|
||||
case PositionType.Before:
|
||||
{
|
||||
var beforeOverallIndex = position.Index.Value;
|
||||
if (beforeOverallIndex < firstPendingIndex)
|
||||
{
|
||||
throw new StepCommandException(StepCommandErrors.InvalidIndex,
|
||||
$"Cannot move before position {beforeOverallIndex} - it is not a pending step.");
|
||||
}
|
||||
var beforeQueueIndex = beforeOverallIndex - firstPendingIndex;
|
||||
// Adjust for removal: items that were after the removed item
|
||||
// have shifted down by 1 in the list
|
||||
if (beforeQueueIndex > fromQueueIndex)
|
||||
beforeQueueIndex--;
|
||||
return Math.Max(0, Math.Min(beforeQueueIndex, listCount));
|
||||
}
|
||||
|
||||
default:
|
||||
return listCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies change tracking info to a StepInfo based on recorded changes.
|
||||
/// </summary>
|
||||
private void ApplyChangeInfo(StepInfo info)
|
||||
{
|
||||
if (info.Step is IActionRunner runner && runner.Action != null)
|
||||
{
|
||||
var actionId = runner.Action.Id;
|
||||
|
||||
if (_addedStepIds.Contains(actionId))
|
||||
{
|
||||
info.Change = ChangeType.Added;
|
||||
}
|
||||
else if (_modifiedStepIds.Contains(actionId))
|
||||
{
|
||||
// Check if it was moved or just modified
|
||||
var moveChange = _changes.LastOrDefault(c =>
|
||||
c.Type == ChangeType.Moved &&
|
||||
c.ModifiedStep?.Action?.Id == actionId);
|
||||
|
||||
info.Change = moveChange != null ? ChangeType.Moved : ChangeType.Modified;
|
||||
info.OriginalIndex = moveChange?.OriginalIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
557
src/Runner.Worker/Dap/StepCommands/StepSerializer.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap.StepCommands
|
||||
{
|
||||
// Note: StepStatus, ChangeType, and StepInfo are now defined in StepInfo.cs
|
||||
|
||||
/// <summary>
|
||||
/// Interface for serializing ActionStep objects to YAML.
|
||||
/// </summary>
|
||||
[ServiceLocator(Default = typeof(StepSerializer))]
|
||||
public interface IStepSerializer : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a single ActionStep to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to serialize</param>
|
||||
/// <returns>YAML representation of the step</returns>
|
||||
string ToYaml(ActionStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a collection of steps to YAML string.
|
||||
/// </summary>
|
||||
/// <param name="steps">The steps to serialize</param>
|
||||
/// <param name="withComments">Whether to include change comments (# ADDED, # MODIFIED)</param>
|
||||
/// <returns>YAML representation of the steps</returns>
|
||||
string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes ActionStep objects to YAML string representation.
|
||||
/// Handles run steps (ScriptReference), uses steps (RepositoryPathReference),
|
||||
/// and docker steps (ContainerRegistryReference).
|
||||
/// </summary>
|
||||
public sealed class StepSerializer : RunnerService, IStepSerializer
|
||||
{
|
||||
// Input keys for script steps (from Inputs MappingToken)
|
||||
private const string ScriptInputKey = "script";
|
||||
private const string ShellInputKey = "shell";
|
||||
private const string WorkingDirectoryInputKey = "workingDirectory";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(ActionStep step)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
WriteStep(sb, step, indent: 0, comment: null);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ToYaml(IEnumerable<StepInfo> steps, bool withComments = false)
|
||||
{
|
||||
if (steps == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("steps:");
|
||||
|
||||
foreach (var stepInfo in steps)
|
||||
{
|
||||
if (stepInfo.Action == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string comment = null;
|
||||
if (withComments && stepInfo.Change.HasValue)
|
||||
{
|
||||
comment = stepInfo.Change.Value switch
|
||||
{
|
||||
ChangeType.Added => "ADDED",
|
||||
ChangeType.Modified => "MODIFIED",
|
||||
ChangeType.Moved => "MOVED",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
WriteStep(sb, stepInfo.Action, indent: 2, comment: comment);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd() + Environment.NewLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a single step to the StringBuilder with proper YAML formatting.
|
||||
/// </summary>
|
||||
private void WriteStep(StringBuilder sb, ActionStep step, int indent, string comment)
|
||||
{
|
||||
var indentStr = new string(' ', indent);
|
||||
|
||||
// Determine step type and write accordingly
|
||||
switch (step.Reference)
|
||||
{
|
||||
case ScriptReference:
|
||||
WriteScriptStep(sb, step, indentStr, comment);
|
||||
break;
|
||||
|
||||
case RepositoryPathReference repoRef:
|
||||
WriteUsesStep(sb, step, repoRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
case ContainerRegistryReference containerRef:
|
||||
WriteDockerStep(sb, step, containerRef, indentStr, comment);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown reference type - write minimal info
|
||||
sb.AppendLine($"{indentStr}- name: {EscapeYamlString(step.DisplayName ?? "Unknown step")}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a run step (ScriptReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteScriptStep(StringBuilder sb, ActionStep step, string indent, string comment)
|
||||
{
|
||||
// Extract script-specific inputs
|
||||
var script = GetInputValue(step.Inputs, ScriptInputKey);
|
||||
var shell = GetInputValue(step.Inputs, ShellInputKey);
|
||||
var workingDirectory = GetInputValue(step.Inputs, WorkingDirectoryInputKey);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// run: ...
|
||||
if (!string.IsNullOrEmpty(script))
|
||||
{
|
||||
WriteRunScript(sb, script, indent);
|
||||
}
|
||||
|
||||
// shell: ...
|
||||
if (!string.IsNullOrEmpty(shell))
|
||||
{
|
||||
sb.AppendLine($"{indent} shell: {shell}");
|
||||
}
|
||||
|
||||
// working-directory: ...
|
||||
if (!string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
sb.AppendLine($"{indent} working-directory: {EscapeYamlString(workingDirectory)}");
|
||||
}
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a uses step (RepositoryPathReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteUsesStep(StringBuilder sb, ActionStep step, RepositoryPathReference repoRef, string indent, string comment)
|
||||
{
|
||||
// Build the uses value
|
||||
var usesValue = BuildUsesValue(repoRef);
|
||||
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: ...
|
||||
sb.AppendLine($"{indent} uses: {usesValue}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a docker step (ContainerRegistryReference) to YAML.
|
||||
/// </summary>
|
||||
private void WriteDockerStep(StringBuilder sb, ActionStep step, ContainerRegistryReference containerRef, string indent, string comment)
|
||||
{
|
||||
// - name: ... # COMMENT
|
||||
var nameComment = comment != null ? $" # {comment}" : "";
|
||||
if (!string.IsNullOrEmpty(step.DisplayName))
|
||||
{
|
||||
sb.AppendLine($"{indent}- name: {EscapeYamlString(step.DisplayName)}{nameComment}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"{indent}-");
|
||||
if (!string.IsNullOrEmpty(nameComment))
|
||||
{
|
||||
sb.AppendLine($"{nameComment}");
|
||||
sb.Append($"{indent} ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// uses: docker://...
|
||||
sb.AppendLine($"{indent} uses: docker://{containerRef.Image}");
|
||||
|
||||
// if: ...
|
||||
WriteCondition(sb, step.Condition, indent);
|
||||
|
||||
// with: ...
|
||||
WriteMappingProperty(sb, "with", step.Inputs, indent);
|
||||
|
||||
// env: ...
|
||||
WriteMappingProperty(sb, "env", step.Environment, indent);
|
||||
|
||||
// continue-on-error: ...
|
||||
WriteBoolOrExprProperty(sb, "continue-on-error", step.ContinueOnError, indent);
|
||||
|
||||
// timeout-minutes: ...
|
||||
WriteNumberOrExprProperty(sb, "timeout-minutes", step.TimeoutInMinutes, indent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uses value from a RepositoryPathReference.
|
||||
/// </summary>
|
||||
private string BuildUsesValue(RepositoryPathReference repoRef)
|
||||
{
|
||||
// Local action: uses: ./path
|
||||
if (string.Equals(repoRef.RepositoryType, "self", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return repoRef.Path ?? ".";
|
||||
}
|
||||
|
||||
// Remote action: uses: owner/repo@ref or uses: owner/repo/path@ref
|
||||
var name = repoRef.Name ?? "";
|
||||
var refValue = repoRef.Ref ?? "";
|
||||
var path = repoRef.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(path) && path != "/" && path != ".")
|
||||
{
|
||||
// Normalize path - remove leading slash if present
|
||||
if (path.StartsWith("/"))
|
||||
{
|
||||
path = path.Substring(1);
|
||||
}
|
||||
return $"{name}/{path}@{refValue}";
|
||||
}
|
||||
|
||||
return $"{name}@{refValue}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a multi-line or single-line run script.
|
||||
/// </summary>
|
||||
private void WriteRunScript(StringBuilder sb, string script, string indent)
|
||||
{
|
||||
if (script.Contains("\n"))
|
||||
{
|
||||
// Multi-line script: use literal block scalar
|
||||
sb.AppendLine($"{indent} run: |");
|
||||
foreach (var line in script.Split('\n'))
|
||||
{
|
||||
// Trim trailing \r if present (Windows line endings)
|
||||
var cleanLine = line.TrimEnd('\r');
|
||||
sb.AppendLine($"{indent} {cleanLine}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-line script
|
||||
sb.AppendLine($"{indent} run: {EscapeYamlString(script)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the if condition if present and not default.
|
||||
/// </summary>
|
||||
private void WriteCondition(StringBuilder sb, string condition, string indent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't write default condition
|
||||
if (condition == "success()")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} if: {EscapeYamlString(condition)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a mapping property (env, with) if it has values.
|
||||
/// </summary>
|
||||
private void WriteMappingProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token is not MappingToken mapping || mapping.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"{indent} {propertyName}:");
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var key = pair.Key?.ToString() ?? "";
|
||||
var value = TokenToYamlValue(pair.Value);
|
||||
sb.AppendLine($"{indent} {key}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a boolean or expression property if not default.
|
||||
/// </summary>
|
||||
private void WriteBoolOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case BooleanToken boolToken:
|
||||
// Only write if true (false is default for continue-on-error)
|
||||
if (boolToken.Value)
|
||||
{
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
}
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when strToken.Value == "true":
|
||||
sb.AppendLine($"{indent} {propertyName}: true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a number or expression property if present.
|
||||
/// </summary>
|
||||
private void WriteNumberOrExprProperty(StringBuilder sb, string propertyName, TemplateToken token, string indent)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NumberToken numToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {(int)numToken.Value}");
|
||||
break;
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
sb.AppendLine($"{indent} {propertyName}: {exprToken.ToString()}");
|
||||
break;
|
||||
|
||||
case StringToken strToken when int.TryParse(strToken.Value, out var intVal):
|
||||
sb.AppendLine($"{indent} {propertyName}: {intVal}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string value from a MappingToken by key.
|
||||
/// </summary>
|
||||
private string GetInputValue(TemplateToken inputs, string key)
|
||||
{
|
||||
if (inputs is not MappingToken mapping)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in mapping)
|
||||
{
|
||||
var keyStr = pair.Key?.ToString();
|
||||
if (string.Equals(keyStr, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pair.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a TemplateToken to a YAML value string.
|
||||
/// </summary>
|
||||
private string TokenToYamlValue(TemplateToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
switch (token)
|
||||
{
|
||||
case NullToken:
|
||||
return "null";
|
||||
|
||||
case BooleanToken boolToken:
|
||||
return boolToken.Value ? "true" : "false";
|
||||
|
||||
case NumberToken numToken:
|
||||
// Use integer if possible, otherwise double
|
||||
if (numToken.Value == Math.Floor(numToken.Value))
|
||||
{
|
||||
return ((long)numToken.Value).ToString();
|
||||
}
|
||||
return numToken.Value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
case StringToken strToken:
|
||||
return EscapeYamlString(strToken.Value);
|
||||
|
||||
case BasicExpressionToken exprToken:
|
||||
return exprToken.ToString();
|
||||
|
||||
default:
|
||||
// For complex types, just use ToString
|
||||
return EscapeYamlString(token.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a string for YAML output if necessary.
|
||||
/// </summary>
|
||||
private string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "''";
|
||||
}
|
||||
|
||||
// Check if value needs quoting
|
||||
var needsQuoting = false;
|
||||
|
||||
// Quote if starts/ends with whitespace
|
||||
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[value.Length - 1]))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Quote if contains special characters that could be misinterpreted
|
||||
if (!needsQuoting)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == ':' || c == '#' || c == '\'' || c == '"' ||
|
||||
c == '{' || c == '}' || c == '[' || c == ']' ||
|
||||
c == ',' || c == '&' || c == '*' || c == '!' ||
|
||||
c == '|' || c == '>' || c == '%' || c == '@' ||
|
||||
c == '`' || c == '\n' || c == '\r')
|
||||
{
|
||||
needsQuoting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote if it looks like a boolean, null, or number
|
||||
if (!needsQuoting)
|
||||
{
|
||||
var lower = value.ToLowerInvariant();
|
||||
if (lower == "true" || lower == "false" || lower == "null" ||
|
||||
lower == "yes" || lower == "no" || lower == "on" || lower == "off" ||
|
||||
lower == "~" || lower == "")
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
|
||||
// Check if it looks like a number
|
||||
if (double.TryParse(value, out _))
|
||||
{
|
||||
needsQuoting = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsQuoting)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Use single quotes and escape single quotes by doubling them
|
||||
if (!value.Contains('\n') && !value.Contains('\r'))
|
||||
{
|
||||
return "'" + value.Replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
// For multi-line strings, use double quotes with escapes
|
||||
return "\"" + value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t") + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
687
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs
Normal file
687
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs
Normal file
@@ -0,0 +1,687 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for StepCommandParser JSON API functionality.
|
||||
/// Tests the parsing of JSON commands for browser extension integration.
|
||||
/// </summary>
|
||||
public sealed class StepCommandParserJsonL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private StepCommandParser _parser;
|
||||
|
||||
public StepCommandParserJsonL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_parser = new StepCommandParser();
|
||||
_parser.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
#region IsStepCommand Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_DetectsJsonFormat()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\":\"step.list\"}"));
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\": \"step.add\", \"type\": \"run\"}"));
|
||||
Assert.True(_parser.IsStepCommand(" { \"cmd\" : \"step.export\" } "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_RejectsInvalidJson()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.False(_parser.IsStepCommand("{\"cmd\":\"other.command\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{\"action\":\"step.list\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{\"type\":\"step\"}"));
|
||||
Assert.False(_parser.IsStepCommand("{}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void IsStepCommand_HandlesBothFormats()
|
||||
{
|
||||
// REPL format
|
||||
Assert.True(_parser.IsStepCommand("!step list"));
|
||||
Assert.True(_parser.IsStepCommand("!STEP ADD run \"test\""));
|
||||
|
||||
// JSON format
|
||||
Assert.True(_parser.IsStepCommand("{\"cmd\":\"step.list\"}"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON List Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ListCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ListCommand>(command);
|
||||
var listCmd = (ListCommand)command;
|
||||
Assert.False(listCmd.Verbose);
|
||||
Assert.True(listCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ListCommand_WithVerbose()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\",\"verbose\":true}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var listCmd = Assert.IsType<ListCommand>(command);
|
||||
Assert.True(listCmd.Verbose);
|
||||
Assert.True(listCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Add Run Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"npm test\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal("npm test", addCmd.Script);
|
||||
Assert.True(addCmd.WasJsonInput);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.add"",
|
||||
""type"": ""run"",
|
||||
""script"": ""npm run build"",
|
||||
""name"": ""Build App"",
|
||||
""shell"": ""bash"",
|
||||
""workingDirectory"": ""./src"",
|
||||
""if"": ""success()"",
|
||||
""env"": {""NODE_ENV"": ""production"", ""CI"": ""true""},
|
||||
""continueOnError"": true,
|
||||
""timeout"": 30,
|
||||
""position"": {""after"": 3}
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal("npm run build", addCmd.Script);
|
||||
Assert.Equal("Build App", addCmd.Name);
|
||||
Assert.Equal("bash", addCmd.Shell);
|
||||
Assert.Equal("./src", addCmd.WorkingDirectory);
|
||||
Assert.Equal("success()", addCmd.Condition);
|
||||
Assert.NotNull(addCmd.Env);
|
||||
Assert.Equal("production", addCmd.Env["NODE_ENV"]);
|
||||
Assert.Equal("true", addCmd.Env["CI"]);
|
||||
Assert.True(addCmd.ContinueOnError);
|
||||
Assert.Equal(30, addCmd.Timeout);
|
||||
Assert.Equal(PositionType.After, addCmd.Position.Type);
|
||||
Assert.Equal(3, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddRunCommand_MissingScript_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("script", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Add Uses Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"uses\",\"action\":\"actions/checkout@v4\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(command);
|
||||
Assert.Equal("actions/checkout@v4", addCmd.Action);
|
||||
Assert.True(addCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.add"",
|
||||
""type"": ""uses"",
|
||||
""action"": ""actions/setup-node@v4"",
|
||||
""name"": ""Setup Node.js"",
|
||||
""with"": {""node-version"": ""20"", ""cache"": ""npm""},
|
||||
""env"": {""NODE_OPTIONS"": ""--max-old-space-size=4096""},
|
||||
""if"": ""always()"",
|
||||
""continueOnError"": false,
|
||||
""timeout"": 10,
|
||||
""position"": {""at"": 2}
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddUsesCommand>(command);
|
||||
Assert.Equal("actions/setup-node@v4", addCmd.Action);
|
||||
Assert.Equal("Setup Node.js", addCmd.Name);
|
||||
Assert.NotNull(addCmd.With);
|
||||
Assert.Equal("20", addCmd.With["node-version"]);
|
||||
Assert.Equal("npm", addCmd.With["cache"]);
|
||||
Assert.NotNull(addCmd.Env);
|
||||
Assert.Equal("--max-old-space-size=4096", addCmd.Env["NODE_OPTIONS"]);
|
||||
Assert.Equal("always()", addCmd.Condition);
|
||||
Assert.False(addCmd.ContinueOnError);
|
||||
Assert.Equal(10, addCmd.Timeout);
|
||||
Assert.Equal(PositionType.At, addCmd.Position.Type);
|
||||
Assert.Equal(2, addCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddUsesCommand_MissingAction_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"uses\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("action", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_AddCommand_InvalidType_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"invalid\",\"script\":\"echo test\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.InvalidType, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Edit Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_Basic()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.edit\",\"index\":3,\"name\":\"New Name\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(command);
|
||||
Assert.Equal(3, editCmd.Index);
|
||||
Assert.Equal("New Name", editCmd.Name);
|
||||
Assert.True(editCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_AllOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = @"{
|
||||
""cmd"": ""step.edit"",
|
||||
""index"": 4,
|
||||
""name"": ""Updated Step"",
|
||||
""script"": ""npm run test:ci"",
|
||||
""shell"": ""pwsh"",
|
||||
""workingDirectory"": ""./tests"",
|
||||
""if"": ""failure()"",
|
||||
""with"": {""key1"": ""value1""},
|
||||
""env"": {""DEBUG"": ""true""},
|
||||
""removeWith"": [""oldKey""],
|
||||
""removeEnv"": [""OBSOLETE""],
|
||||
""continueOnError"": true,
|
||||
""timeout"": 15
|
||||
}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var editCmd = Assert.IsType<EditCommand>(command);
|
||||
Assert.Equal(4, editCmd.Index);
|
||||
Assert.Equal("Updated Step", editCmd.Name);
|
||||
Assert.Equal("npm run test:ci", editCmd.Script);
|
||||
Assert.Equal("pwsh", editCmd.Shell);
|
||||
Assert.Equal("./tests", editCmd.WorkingDirectory);
|
||||
Assert.Equal("failure()", editCmd.Condition);
|
||||
Assert.NotNull(editCmd.With);
|
||||
Assert.Equal("value1", editCmd.With["key1"]);
|
||||
Assert.NotNull(editCmd.Env);
|
||||
Assert.Equal("true", editCmd.Env["DEBUG"]);
|
||||
Assert.NotNull(editCmd.RemoveWith);
|
||||
Assert.Contains("oldKey", editCmd.RemoveWith);
|
||||
Assert.NotNull(editCmd.RemoveEnv);
|
||||
Assert.Contains("OBSOLETE", editCmd.RemoveEnv);
|
||||
Assert.True(editCmd.ContinueOnError);
|
||||
Assert.Equal(15, editCmd.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EditCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.edit\",\"name\":\"New Name\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("index", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Remove Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_RemoveCommand()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.remove\",\"index\":5}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var removeCmd = Assert.IsType<RemoveCommand>(command);
|
||||
Assert.Equal(5, removeCmd.Index);
|
||||
Assert.True(removeCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_RemoveCommand_MissingIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.remove\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("index", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Move Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_After()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"after\":2}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(5, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.After, moveCmd.Position.Type);
|
||||
Assert.Equal(2, moveCmd.Position.Index);
|
||||
Assert.True(moveCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_Before()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":3,\"position\":{\"before\":5}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(3, moveCmd.FromIndex);
|
||||
Assert.Equal(PositionType.Before, moveCmd.Position.Type);
|
||||
Assert.Equal(5, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_First()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"first\":true}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.First, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_Last()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":2,\"position\":{\"last\":true}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.Last, moveCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_At()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5,\"position\":{\"at\":3}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var moveCmd = Assert.IsType<MoveCommand>(command);
|
||||
Assert.Equal(PositionType.At, moveCmd.Position.Type);
|
||||
Assert.Equal(3, moveCmd.Position.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_MissingFrom_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"position\":{\"after\":2}}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("from", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MoveCommand_MissingPosition_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.move\",\"from\":5}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("position", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Export Command Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ExportCommand_Default()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.export\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(command);
|
||||
Assert.False(exportCmd.ChangesOnly);
|
||||
Assert.False(exportCmd.WithComments);
|
||||
Assert.True(exportCmd.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_ExportCommand_WithOptions()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.export\",\"changesOnly\":true,\"withComments\":true}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var exportCmd = Assert.IsType<ExportCommand>(command);
|
||||
Assert.True(exportCmd.ChangesOnly);
|
||||
Assert.True(exportCmd.WithComments);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_InvalidJson_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{invalid json}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("Invalid JSON", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_MissingCmd_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"action\":\"list\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
Assert.Contains("cmd", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_UnknownCommand_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.unknown\"}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.InvalidCommand, ex.ErrorCode);
|
||||
Assert.Contains("unknown", ex.Message.ToLower());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EmptyJson_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{}";
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _parser.Parse(json));
|
||||
Assert.Equal(StepCommandErrors.ParseError, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Position Parsing Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_PositionDefaults_ToLast()
|
||||
{
|
||||
// Arrange - position is optional for add commands
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_NullPosition_DefaultsToLast()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\",\"position\":null}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ParseJson_EmptyPosition_DefaultsToLast()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.add\",\"type\":\"run\",\"script\":\"test\",\"position\":{}}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
var addCmd = Assert.IsType<AddRunCommand>(command);
|
||||
Assert.Equal(PositionType.Last, addCmd.Position.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WasJsonInput Flag Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_JsonInput_SetsWasJsonInputTrue()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{\"cmd\":\"step.list\"}";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(command.WasJsonInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Parse_ReplInput_SetsWasJsonInputFalse()
|
||||
{
|
||||
// Arrange
|
||||
var repl = "!step list";
|
||||
|
||||
// Act
|
||||
var command = _parser.Parse(repl);
|
||||
|
||||
// Assert
|
||||
Assert.False(command.WasJsonInput);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
725
src/Test/L0/Worker/Dap/StepCommands/StepManipulatorL0.cs
Normal file
@@ -0,0 +1,725 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap.StepCommands;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker.Dap.StepCommands
|
||||
{
|
||||
public sealed class StepManipulatorL0 : IDisposable
|
||||
{
|
||||
private TestHostContext _hc;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private StepManipulator _manipulator;
|
||||
private Queue<IStep> _jobSteps;
|
||||
|
||||
public StepManipulatorL0()
|
||||
{
|
||||
_hc = new TestHostContext(this);
|
||||
_manipulator = new StepManipulator();
|
||||
_manipulator.Initialize(_hc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
|
||||
private void SetupJobContext(int pendingStepCount = 3)
|
||||
{
|
||||
_jobSteps = new Queue<IStep>();
|
||||
for (int i = 0; i < pendingStepCount; i++)
|
||||
{
|
||||
var step = CreateMockStep($"Step {i + 1}");
|
||||
_jobSteps.Enqueue(step);
|
||||
}
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.JobSteps).Returns(_jobSteps);
|
||||
|
||||
_manipulator.Initialize(_ec.Object, 0);
|
||||
}
|
||||
|
||||
private IStep CreateMockStep(string displayName, bool isActionRunner = true)
|
||||
{
|
||||
if (isActionRunner)
|
||||
{
|
||||
var actionRunner = new Mock<IActionRunner>();
|
||||
var actionStep = new ActionStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"_step_{Guid.NewGuid():N}",
|
||||
DisplayName = displayName,
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = CreateScriptInputs("echo hello"),
|
||||
Condition = "success()"
|
||||
};
|
||||
actionRunner.Setup(x => x.DisplayName).Returns(displayName);
|
||||
actionRunner.Setup(x => x.Action).Returns(actionStep);
|
||||
actionRunner.Setup(x => x.Condition).Returns("success()");
|
||||
return actionRunner.Object;
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = new Mock<IStep>();
|
||||
step.Setup(x => x.DisplayName).Returns(displayName);
|
||||
step.Setup(x => x.Condition).Returns("success()");
|
||||
return step.Object;
|
||||
}
|
||||
}
|
||||
|
||||
private MappingToken CreateScriptInputs(string script)
|
||||
{
|
||||
var inputs = new MappingToken(null, null, null);
|
||||
inputs.Add(
|
||||
new StringToken(null, null, null, "script"),
|
||||
new StringToken(null, null, null, script));
|
||||
return inputs;
|
||||
}
|
||||
|
||||
#region Initialization Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_SetsJobContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext();
|
||||
|
||||
// Act & Assert - no exception means success
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal(3, steps.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Initialize_ThrowsOnNullContext()
|
||||
{
|
||||
// Arrange
|
||||
var manipulator = new StepManipulator();
|
||||
manipulator.Initialize(_hc);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => manipulator.Initialize(null, 0));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllSteps Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_ReturnsPendingSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.All(steps, s => Assert.Equal(StepStatus.Pending, s.Status));
|
||||
Assert.Equal(1, steps[0].Index);
|
||||
Assert.Equal(2, steps[1].Index);
|
||||
Assert.Equal(3, steps[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCompletedSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var completedStep = CreateMockStep("Completed Step");
|
||||
_manipulator.AddCompletedStep(completedStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Completed, steps[0].Status);
|
||||
Assert.Equal("Completed Step", steps[0].Name);
|
||||
Assert.Equal(StepStatus.Pending, steps[1].Status);
|
||||
Assert.Equal(StepStatus.Pending, steps[2].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetAllSteps_IncludesCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var currentStep = CreateMockStep("Current Step");
|
||||
_manipulator.SetCurrentStep(currentStep);
|
||||
|
||||
// Act
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, steps.Count);
|
||||
Assert.Equal(StepStatus.Current, steps[0].Status);
|
||||
Assert.Equal("Current Step", steps[0].Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsCorrectStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var step = _manipulator.GetStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(step);
|
||||
Assert.Equal(2, step.Index);
|
||||
Assert.Equal("Step 2", step.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetStep_ReturnsNullForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(_manipulator.GetStep(0));
|
||||
Assert.Null(_manipulator.GetStep(4));
|
||||
Assert.Null(_manipulator.GetStep(-1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPendingCount Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetPendingCount_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(5);
|
||||
|
||||
// Act
|
||||
var count = _manipulator.GetPendingCount();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetFirstPendingIndex Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoPriorSteps_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithCompletedSteps_ReturnsCorrectIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 1"));
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed 2"));
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void GetFirstPendingIndex_WithNoSteps_ReturnsNegativeOne()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(0);
|
||||
|
||||
// Act
|
||||
var index = _manipulator.GetFirstPendingIndex();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-1, index);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InsertStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtLast_AppendsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
Assert.Equal(3, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtFirst_PrependsToQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("New Step", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AtPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.At(2));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_AfterPosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.After(1));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("New Step", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_BeforePosition_InsertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
var index = _manipulator.InsertStep(newStep, StepPosition.Before(3));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 2", steps[1].Name);
|
||||
Assert.Equal("New Step", steps[2].Name);
|
||||
Assert.Equal("Step 3", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void InsertStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
var newStep = CreateMockStep("New Step");
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(newStep, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, _jobSteps.Count);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 1", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RemoveStep(2);
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Removed, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].OriginalIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForCurrentStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.SetCurrentStep(CreateMockStep("Current Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(1));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RemoveStep_ThrowsForInvalidIndex()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(0));
|
||||
Assert.Throws<StepCommandException>(() => _manipulator.RemoveStep(4));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MoveStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToLast_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 3", steps[1].Name);
|
||||
Assert.Equal("Step 1", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToFirst_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
var newIndex = _manipulator.MoveStep(3, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, newIndex);
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 3", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 2", steps[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_ToMiddle_MovesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(4);
|
||||
|
||||
// Act - move step 1 to after step 2 (which becomes position 2)
|
||||
var newIndex = _manipulator.MoveStep(1, StepPosition.After(2));
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
Assert.Equal("Step 2", steps[0].Name);
|
||||
Assert.Equal("Step 1", steps[1].Name);
|
||||
Assert.Equal("Step 3", steps[2].Name);
|
||||
Assert.Equal("Step 4", steps[3].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MoveStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.MoveStep(1, StepPosition.Last());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Moved, changes[0].Type);
|
||||
Assert.Equal(1, changes[0].OriginalIndex);
|
||||
Assert.Equal(3, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EditStep Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ModifiesActionStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var steps = _manipulator.GetAllSteps();
|
||||
var actionRunner = steps[1].Step as IActionRunner;
|
||||
Assert.NotNull(actionRunner);
|
||||
Assert.Equal("Modified Step", actionRunner.Action.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_TracksChange()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.EditStep(2, step =>
|
||||
{
|
||||
step.DisplayName = "Modified Step";
|
||||
});
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
Assert.Equal(ChangeType.Modified, changes[0].Type);
|
||||
Assert.Equal(2, changes[0].CurrentIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EditStep_ThrowsForCompletedStep()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(2);
|
||||
_manipulator.AddCompletedStep(CreateMockStep("Completed Step"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<StepCommandException>(() =>
|
||||
_manipulator.EditStep(1, step => { }));
|
||||
Assert.Equal(StepCommandErrors.InvalidIndex, ex.ErrorCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Change Tracking Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void RecordOriginalState_CapturesSteps()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.RecordOriginalState();
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Assert - changes should be tracked
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ClearChanges_RemovesAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
|
||||
// Act
|
||||
_manipulator.ClearChanges();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(_manipulator.GetChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void MultipleOperations_TrackAllChanges()
|
||||
{
|
||||
// Arrange
|
||||
SetupJobContext(3);
|
||||
|
||||
// Act
|
||||
_manipulator.InsertStep(CreateMockStep("New Step"), StepPosition.Last());
|
||||
_manipulator.RemoveStep(1);
|
||||
_manipulator.MoveStep(2, StepPosition.First());
|
||||
|
||||
// Assert
|
||||
var changes = _manipulator.GetChanges();
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.Equal(ChangeType.Added, changes[0].Type);
|
||||
Assert.Equal(ChangeType.Removed, changes[1].Type);
|
||||
Assert.Equal(ChangeType.Moved, changes[2].Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StepInfo Factory Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_ExtractsRunStepInfo()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Test Run Step");
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Run Step", info.Name);
|
||||
Assert.Equal("run", info.Type);
|
||||
Assert.Equal(StepStatus.Pending, info.Status);
|
||||
Assert.NotNull(info.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void StepInfo_FromStep_HandlesNonActionRunner()
|
||||
{
|
||||
// Arrange
|
||||
var step = CreateMockStep("Extension Step", isActionRunner: false);
|
||||
|
||||
// Act
|
||||
var info = StepInfo.FromStep(step, 1, StepStatus.Pending);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Extension Step", info.Name);
|
||||
Assert.Equal("extension", info.Type);
|
||||
Assert.Null(info.Action);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user