editing jobs

This commit is contained in:
Francesco Renzi
2026-01-21 22:30:19 +00:00
committed by GitHub
parent 9bc9aff86f
commit 008594a3ee
14 changed files with 6450 additions and 9 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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&#10;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);

View File

@@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Dap.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;

View 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}";
}
}
}

View 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
}
}

View 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
}
}

View 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);
}
}
}

View 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
}
}

View 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}";
}
}
}

View 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
}
}

View 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") + "\"";
}
}
}

View 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
}
}

View 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
}
}