From cd5578bf57fd168e1e64ceb03b9276e869482c05 Mon Sep 17 00:00:00 2001 From: Ambuj Date: Sun, 23 Nov 2025 17:51:31 +0530 Subject: [PATCH 1/3] Feat: Added a copy to clipboard features for copying the test id. --- src/layout/css/style.scss | 40 +++++++++++++++++++++++++++++++++ src/pytest_html/basereport.py | 18 ++++++++++++++- src/pytest_html/scripts/main.js | 19 ++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/layout/css/style.scss b/src/layout/css/style.scss index b4bf0eee..934c9104 100644 --- a/src/layout/css/style.scss +++ b/src/layout/css/style.scss @@ -116,6 +116,46 @@ span.xpassed, } } +.col-testId { + position: relative; + + .copy-btn { + margin-left: 8px; + padding: 3px 5px; + border: 1px solid #ccc; + border-radius: 3px; + background-color: transparent; + cursor: pointer; + font-size: 0; + line-height: 1; + vertical-align: middle; + transition: all 0.15s ease; + color: #999; + + svg { + display: block; + width: 12px; + height: 12px; + } + + &:hover { + background-color: #f6f6f6; + border-color: #999; + color: #666; + } + + &:active { + background-color: #e6e6e6; + } + + &.copied { + background-color: #4caf4faa; + border-color: #4caf4faa; + color: white; + } + } +} + /*------------------ * 2. Extra *------------------*/ diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py index cfcc74b2..58e32a1c 100644 --- a/src/pytest_html/basereport.py +++ b/src/pytest_html/basereport.py @@ -284,7 +284,23 @@ def _process_report(self, report, duration, processed_extras): ] cells = [ f'{outcome}', - f'{test_id}', + f"""{test_id} + + """, f'{formatted_duration}', f'{_process_links(links)}', ] diff --git a/src/pytest_html/scripts/main.js b/src/pytest_html/scripts/main.js index f01f2eac..61354bd9 100644 --- a/src/pytest_html/scripts/main.js +++ b/src/pytest_html/scripts/main.js @@ -70,6 +70,10 @@ const renderContent = (tests) => { 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) } }) @@ -86,6 +90,21 @@ const renderDerived = () => { }) } +const handleCopyTestId = (evt) => { + evt.stopPropagation() + const button = evt.currentTarget + const testId = button.dataset.testId + + navigator.clipboard.writeText(testId).then(() => { + button.classList.add('copied') + setTimeout(() => { + button.classList.remove('copied') + }, 500) + }).catch(() => { + // Silently fail if clipboard API unavailable + }) +} + const bindEvents = () => { const filterColumn = (evt) => { const { target: element } = evt From 4cd80dea7baf827f771e9b6a94ae7361cd7830be Mon Sep 17 00:00:00 2001 From: Ambuj Date: Wed, 6 May 2026 16:11:53 +0530 Subject: [PATCH 2/3] Use unicode instead of svg icon --- src/layout/css/style.scss | 21 +++++---------------- src/pytest_html/basereport.py | 20 +++----------------- src/pytest_html/resources/style.css | 24 ++++++++++++++++++++++++ src/pytest_html/scripts/main.js | 16 ++++++++-------- 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/layout/css/style.scss b/src/layout/css/style.scss index 934c9104..32e3fa17 100644 --- a/src/layout/css/style.scss +++ b/src/layout/css/style.scss @@ -117,31 +117,21 @@ span.xpassed, } .col-testId { - position: relative; - .copy-btn { margin-left: 8px; - padding: 3px 5px; - border: 1px solid #ccc; + padding: 2px 4px; + border: $border-width solid #ccc; border-radius: 3px; background-color: transparent; cursor: pointer; - font-size: 0; + font-size: $font-size-text; line-height: 1; vertical-align: middle; transition: all 0.15s ease; - color: #999; - - svg { - display: block; - width: 12px; - height: 12px; - } &:hover { background-color: #f6f6f6; border-color: #999; - color: #666; } &:active { @@ -149,9 +139,8 @@ span.xpassed, } &.copied { - background-color: #4caf4faa; - border-color: #4caf4faa; - color: white; + background-color: #e6e6e6; + border-color: #999; } } } diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py index 58e32a1c..98be870d 100644 --- a/src/pytest_html/basereport.py +++ b/src/pytest_html/basereport.py @@ -284,23 +284,9 @@ def _process_report(self, report, duration, processed_extras): ] cells = [ f'{outcome}', - f"""{test_id} - - """, + f'{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 61354bd9..643efce9 100644 --- a/src/pytest_html/scripts/main.js +++ b/src/pytest_html/scripts/main.js @@ -82,14 +82,6 @@ const renderContent = (tests) => { table.replaceWith(newTable) } -const renderDerived = () => { - const currentFilter = getVisible() - possibleFilters.forEach((result) => { - const input = document.querySelector(`input[data-test-result="${result}"]`) - input.checked = currentFilter.includes(result) - }) -} - const handleCopyTestId = (evt) => { evt.stopPropagation() const button = evt.currentTarget @@ -105,6 +97,14 @@ const handleCopyTestId = (evt) => { }) } +const renderDerived = () => { + const currentFilter = getVisible() + possibleFilters.forEach((result) => { + const input = document.querySelector(`input[data-test-result="${result}"]`) + input.checked = currentFilter.includes(result) + }) +} + const bindEvents = () => { const filterColumn = (evt) => { const { target: element } = evt From aa8c10105016f134395b653c972af960664346a8 Mon Sep 17 00:00:00 2001 From: Ambuj Date: Wed, 6 May 2026 16:51:50 +0530 Subject: [PATCH 3/3] Add clipboard capability check with fallback and escape test_id for HTML safety --- src/pytest_html/basereport.py | 5 ++-- src/pytest_html/scripts/main.js | 46 ++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py index 98be870d..1078c70d 100644 --- a/src/pytest_html/basereport.py +++ b/src/pytest_html/basereport.py @@ -282,10 +282,11 @@ 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'', f'{formatted_duration}', f'{_process_links(links)}', diff --git a/src/pytest_html/scripts/main.js b/src/pytest_html/scripts/main.js index 643efce9..d2264462 100644 --- a/src/pytest_html/scripts/main.js +++ b/src/pytest_html/scripts/main.js @@ -66,7 +66,7 @@ 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'), ) @@ -87,14 +87,42 @@ const handleCopyTestId = (evt) => { const button = evt.currentTarget const testId = button.dataset.testId - navigator.clipboard.writeText(testId).then(() => { - button.classList.add('copied') - setTimeout(() => { - button.classList.remove('copied') - }, 500) - }).catch(() => { - // Silently fail if clipboard API unavailable - }) + 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 = () => {