diff --git a/src/layout/css/style.scss b/src/layout/css/style.scss index b4bf0eee..32e3fa17 100644 --- a/src/layout/css/style.scss +++ b/src/layout/css/style.scss @@ -116,6 +116,35 @@ span.xpassed, } } +.col-testId { + .copy-btn { + margin-left: 8px; + padding: 2px 4px; + border: $border-width solid #ccc; + border-radius: 3px; + background-color: transparent; + cursor: pointer; + font-size: $font-size-text; + line-height: 1; + vertical-align: middle; + transition: all 0.15s ease; + + &:hover { + background-color: #f6f6f6; + border-color: #999; + } + + &:active { + background-color: #e6e6e6; + } + + &.copied { + background-color: #e6e6e6; + border-color: #999; + } + } +} + /*------------------ * 2. Extra *------------------*/ diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py index cfcc74b2..1078c70d 100644 --- a/src/pytest_html/basereport.py +++ b/src/pytest_html/basereport.py @@ -282,9 +282,12 @@ def _process_report(self, report, duration, processed_extras): for extra in data["extras"] if extra["format_type"] in ["json", "text", "url"] ] + escaped_test_id = escape(test_id, quote=True) cells = [ f'{outcome}', - f'{test_id}', + f'{escaped_test_id}' + f'', f'{formatted_duration}', f'{_process_links(links)}', ] diff --git a/src/pytest_html/resources/style.css b/src/pytest_html/resources/style.css index 561524c6..4fea651a 100644 --- a/src/pytest_html/resources/style.css +++ b/src/pytest_html/resources/style.css @@ -101,6 +101,30 @@ span.xpassed, font-weight: bold; } +.col-testId .copy-btn { + margin-left: 8px; + padding: 2px 4px; + border: 1px solid #ccc; + border-radius: 3px; + background-color: transparent; + cursor: pointer; + font-size: 12px; + line-height: 1; + vertical-align: middle; + transition: all 0.15s ease; +} +.col-testId .copy-btn:hover { + background-color: #f6f6f6; + border-color: #999; +} +.col-testId .copy-btn:active { + background-color: #e6e6e6; +} +.col-testId .copy-btn.copied { + background-color: #e6e6e6; + border-color: #999; +} + /*------------------ * 2. Extra *------------------*/ diff --git a/src/pytest_html/scripts/main.js b/src/pytest_html/scripts/main.js index f01f2eac..d2264462 100644 --- a/src/pytest_html/scripts/main.js +++ b/src/pytest_html/scripts/main.js @@ -66,10 +66,14 @@ const renderContent = (tests) => { } else { rows.forEach((row) => { if (!!row) { - findAll('.collapsible td:not(.col-links', row).forEach(addItemToggleListener) + findAll('.collapsible td:not(.col-links)', row).forEach(addItemToggleListener) find('.logexpander', row).addEventListener('click', (evt) => evt.target.parentNode.classList.toggle('expanded'), ) + const copyBtn = find('.copy-btn', row) + if (copyBtn) { + copyBtn.addEventListener('click', handleCopyTestId) + } newTable.appendChild(row) } }) @@ -78,6 +82,49 @@ const renderContent = (tests) => { table.replaceWith(newTable) } +const handleCopyTestId = (evt) => { + evt.stopPropagation() + const button = evt.currentTarget + const testId = button.dataset.testId + + const copyToClipboard = () => { + // Try modern Clipboard API first + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(testId) + } + + // Fallback for older browsers / file:// contexts + const textArea = document.createElement('textarea') + textArea.value = testId + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + document.body.appendChild(textArea) + textArea.select() + + try { + document.execCommand('copy') + document.body.removeChild(textArea) + return Promise.resolve() + } catch (err) { + document.body.removeChild(textArea) + return Promise.reject(err) + } + } + + try { + copyToClipboard().then(() => { + button.classList.add('copied') + setTimeout(() => { + button.classList.remove('copied') + }, 500) + }).catch(() => { + // Silently fail if clipboard API unavailable + }) + } catch (_err) { + // Silently fail on unexpected errors + } +} + const renderDerived = () => { const currentFilter = getVisible() possibleFilters.forEach((result) => {