Azure DevOps List Test Names v1.0.1

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: List Test Names
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.1
// @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;
                bottom: 24px;
                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;
                bottom: 64px;
                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: 50vh;
                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`);
})();