Azure DevOps List Test Names v1.0.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: List Test Names
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Shows a floating panel listing unit test names when viewing a test class file in an Azure DevOps PR
// @author Tim Hilton using Claude
// @match https://dev.azure.com/*/_git/*/pullrequest/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = '[Azure DevOps: List Test Names]';
const POLL_INTERVAL_MS = 800;
const TEST_ATTRIBUTE_RE = /\[(Fact|Theory|Test|TestCase|TestMethod|DataTestMethod)\b/;
const METHOD_SIGNATURE_RE = /(?:public|private|protected|internal)\s+(?:async\s+)?[\w<>\[\],\s]+?\s(\w+)\s*\(/;
const METHOD_LOOKAHEAD_LINES = 30;
// Finds test method names by looking for a test attribute (e.g. [Fact]) and then
// scanning forward for the method signature it decorates. A generous lookahead is
// needed to skip over multi-line [InlineData(...)] stacks above [Theory] methods.
function extractTestNames(lines) {
const results = [];
let lastConsumedLine = -1;
for (let i = 0; i < lines.length; i++) {
if (!TEST_ATTRIBUTE_RE.test(lines[i])) continue;
const scanLimit = Math.min(i + METHOD_LOOKAHEAD_LINES, lines.length);
for (let j = i + 1; j < scanLimit; j++) {
const match = lines[j].match(METHOD_SIGNATURE_RE);
if (match) {
// Stacked attributes (e.g. [Fact] plus a trait attribute) resolve forward
// to the same method line, so dedup by line to avoid duplicate entries.
if (j !== lastConsumedLine) {
results.push({ name: match[1], line: j + 1 });
lastConsumedLine = j;
}
break;
}
}
}
return results;
}
function getCurrentFilePath() {
return new URLSearchParams(window.location.search).get('path');
}
function getModelForPath(filePath) {
if (!filePath || typeof window.monaco === 'undefined') return null;
// Monaco models accumulate as the user browses between files, and the "original"
// (before-change) side of a diff is an anonymous model with no path in its URI, so
// filtering by path alone reliably picks the current/modified content.
const matches = window.monaco.editor.getModels().filter(model => model.uri.toString().endsWith(filePath));
return matches[matches.length - 1] || null;
}
function injectStyles() {
console.debug(`${LOG_PREFIX} Injecting styles`);
const style = document.createElement('style');
style.textContent = `
.test-list-toggle-button {
display: none;
position: fixed;
top: 90px;
right: 24px;
z-index: 999999;
padding: 6px 12px;
background: rgba(20, 20, 20, 0.95);
border: 1px solid #e05252;
border-radius: 4px;
color: #ff6b6b;
font-family: 'SF Mono', Consolas, monospace;
font-size: 13px;
cursor: pointer;
}
.test-list-panel {
position: fixed;
top: 130px;
right: 24px;
z-index: 999999;
background: rgba(20, 20, 20, 0.95);
border: 1px solid #e05252;
border-radius: 4px;
padding: 12px 16px;
font-family: 'SF Mono', Consolas, monospace;
font-size: 13px;
color: #ff6b6b;
max-height: 60vh;
overflow-y: auto;
display: none;
}
.test-list-panel .test-list-entry {
white-space: nowrap;
}
.test-list-panel .test-list-entry-line {
color: #a04040;
margin-left: 8px;
}
`;
document.head.appendChild(style);
}
let toggleButton = null;
let panel = null;
let panelOpen = false;
function createUi() {
toggleButton = document.createElement('button');
toggleButton.classList.add('test-list-toggle-button');
toggleButton.textContent = 'Tests';
toggleButton.addEventListener('click', () => {
panelOpen = !panelOpen;
panel.style.display = panelOpen ? 'block' : 'none';
});
panel = document.createElement('div');
panel.classList.add('test-list-panel');
document.body.appendChild(toggleButton);
document.body.appendChild(panel);
}
function showUi(tests) {
panel.innerHTML = '';
tests.forEach(test => {
const entry = document.createElement('div');
entry.classList.add('test-list-entry');
entry.textContent = test.name;
const line = document.createElement('span');
line.classList.add('test-list-entry-line');
line.textContent = `:${test.line}`;
entry.appendChild(line);
panel.appendChild(entry);
});
toggleButton.style.display = 'block';
panel.style.display = panelOpen ? 'block' : 'none';
}
function hideUi() {
toggleButton.style.display = 'none';
panel.style.display = 'none';
panelOpen = false;
}
let lastRenderedPath = null;
function update() {
const currentPath = getCurrentFilePath();
if (currentPath === lastRenderedPath) return;
if (!currentPath) {
hideUi();
lastRenderedPath = currentPath;
return;
}
const model = getModelForPath(currentPath);
if (!model) {
// Monaco may still be loading the model for a file that was just navigated to;
// hide any stale panel from the previous file and retry on the next poll.
hideUi();
return;
}
const tests = extractTestNames(model.getLinesContent());
console.debug(`${LOG_PREFIX} Found ${tests.length} test(s) in ${currentPath}`);
if (tests.length > 0) {
showUi(tests);
} else {
hideUi();
}
lastRenderedPath = currentPath;
}
console.debug(`${LOG_PREFIX} Initializing script`);
injectStyles();
createUi();
update();
setInterval(update, POLL_INTERVAL_MS);
console.log(`${LOG_PREFIX} 🎉 Script initialized successfully`);
})();