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) => {