diff --git a/assets/admin.scss b/assets/admin.scss
index fc67ee3..4a42678 100644
--- a/assets/admin.scss
+++ b/assets/admin.scss
@@ -62,6 +62,13 @@
// }
}
+.column-status .vrts-testing-status-wrapper {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
.vrts-test-runs-list-table:not(.vrts-test-runs-list-queue-table) {
.test-run-row[data-has-alerts] {
@@ -79,7 +86,6 @@
}
}
-
&.column-title {
position: relative;
display: flex;
@@ -176,7 +182,6 @@
&--manual {
background: rgba(5, 116, 206, 0.1);
color: #045495;
-
}
&--update {
@@ -239,6 +244,35 @@
}
}
+.vrts-tooltip-popup {
+ position: fixed;
+ z-index: 99999999999;
+ pointer-events: none;
+ animation: vrts-fade-in 0.15s ease-in-out;
+
+ .vrts-tooltip-content-inner {
+ display: block;
+ max-width: 200px;
+ background-color: #1e1e1e;
+ color: #f0f0f0;
+ font-size: 0.75rem;
+ line-height: 1.4;
+ padding: 8px 10px;
+ border-radius: 6px;
+ box-shadow: 2px 2px 8px 4px rgba(30, 30, 30, 0.12);
+ -webkit-font-smoothing: antialiased;
+ }
+
+ &__arrow {
+ position: absolute;
+ left: 50%;
+ top: 100%;
+ transform: translateX(-50%);
+ border: 6px solid transparent;
+ border-top-color: #1e1e1e;
+ }
+}
+
.vrts-testing-toogle {
display: flex;
align-items: center;
@@ -330,6 +364,38 @@
}
}
+.vrts-gradient-border {
+ border-radius: inherit;
+ inset: 0;
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ opacity: 0;
+ overflow: hidden;
+ padding: 0;
+ pointer-events: none;
+ position: absolute;
+ transition:
+ padding 0.2s,
+ opacity 0.2s;
+ z-index: 0;
+
+ &::before {
+ animation: vrts-rotate 2s linear infinite;
+ animation-play-state: paused;
+ aspect-ratio: 1 / 1;
+ background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%);
+ block-size: auto;
+ content: "";
+ inline-size: 200%;
+ inset-block-start: 50%;
+ inset-inline-start: 50%;
+ position: absolute;
+ translate: -50% -50%;
+ }
+}
+
.vrts-gradient-loader {
position: absolute;
top: 1px;
@@ -339,7 +405,9 @@
background: linear-gradient(90deg, #ddd, #fff, #ddd);
background-size: 200% 100%;
animation: vrts-shimmer 1.5s linear paused infinite;
- transition: opacity 0.2s, visibility 0.2s;
+ transition:
+ opacity 0.2s,
+ visibility 0.2s;
opacity: 0;
visibility: hidden;
z-index: 10;
diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg
new file mode 100644
index 0000000..70aeb91
--- /dev/null
+++ b/assets/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg
new file mode 100644
index 0000000..055ce61
--- /dev/null
+++ b/assets/icons/settings.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/wand-magic-sparkles.svg b/assets/icons/wand-magic-sparkles.svg
new file mode 100644
index 0000000..9312ee0
--- /dev/null
+++ b/assets/icons/wand-magic-sparkles.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/styles/animations.scss b/assets/styles/animations.scss
index a025c53..c2abfc7 100644
--- a/assets/styles/animations.scss
+++ b/assets/styles/animations.scss
@@ -85,3 +85,14 @@
background-color: rgba(32, 113, 177, 0.2);
}
}
+
+@keyframes vrts-gradient-text {
+
+ 0% {
+ background-position: 0% 0;
+ }
+
+ 100% {
+ background-position: -200% 0;
+ }
+}
diff --git a/components/test-settings/_style.scss b/components/test-settings/_style.scss
new file mode 100644
index 0000000..6a91045
--- /dev/null
+++ b/components/test-settings/_style.scss
@@ -0,0 +1,295 @@
+$vrts-settings-icon-mask: "data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 18C10 19.6568 8.65686 21 7 21C5.34314 21 4 19.6568 4 18C4 16.3432 5.34314 15 7 15C8.65686 15 10 16.3432 10 18Z' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M20 6C20 4.34314 18.6568 3 17 3C15.3432 3 14 4.34314 14 6C14 7.65686 15.3432 9 17 9C18.6568 9 20 7.65686 20 6Z' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 15V3' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M17 9V21' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E";
+$vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath d='M295.4 37L310.2 73.8L347 88.6C350 89.8 352 92.8 352 96C352 99.2 350 102.2 347 103.4L310.2 118.2L295.4 155C294.2 158 291.2 160 288 160C284.8 160 281.8 158 280.6 155L265.8 118.2L229 103.4C226 102.2 224 99.2 224 96C224 92.8 226 89.8 229 88.6L265.8 73.8L280.6 37C281.8 34 284.8 32 288 32C291.2 32 294.2 34 295.4 37zM142.7 105.7L164.2 155.8L214.3 177.3C220.2 179.8 224 185.6 224 192C224 198.4 220.2 204.2 214.3 206.7L164.2 228.2L142.7 278.3C140.2 284.2 134.4 288 128 288C121.6 288 115.8 284.2 113.3 278.3L91.8 228.2L41.7 206.7C35.8 204.2 32 198.4 32 192C32 185.6 35.8 179.8 41.7 177.3L91.8 155.8L113.3 105.7C115.8 99.8 121.6 96 128 96C134.4 96 140.2 99.8 142.7 105.7zM496 368C502.4 368 508.2 371.8 510.7 377.7L532.2 427.8L582.3 449.3C588.2 451.8 592 457.6 592 464C592 470.4 588.2 476.2 582.3 478.7L532.2 500.2L510.7 550.3C508.2 556.2 502.4 560 496 560C489.6 560 483.8 556.2 481.3 550.3L459.8 500.2L409.7 478.7C403.8 476.2 400 470.4 400 464C400 457.6 403.8 451.8 409.7 449.3L459.8 427.8L481.3 377.7C483.8 371.8 489.6 368 496 368zM492 64C503 64 513.6 68.4 521.5 76.2L563.8 118.5C571.6 126.4 576 137 576 148C576 159 571.6 169.6 563.8 177.5L475.6 265.7L374.3 164.4L462.5 76.2C470.4 68.4 481 64 492 64zM76.2 462.5L340.4 198.3L441.7 299.6L177.5 563.8C169.6 571.6 159 576 148 576C137 576 126.4 571.6 118.5 563.8L76.2 521.5C68.4 513.6 64 503 64 492C64 481 68.4 470.4 76.2 462.5z' fill='%23000'/%3E%3C/svg%3E";
+
+.vrts-test-settings-button {
+ all: unset;
+ cursor: pointer;
+ line-height: 0;
+ color: #757575;
+ border-radius: 20px;
+ width: 24px;
+ position: relative;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ corner-shape: squircle; // stylelint-disable-line property-no-unknown
+ transition:
+ color 0.2s,
+ background-color 0.2s;
+
+ .vrts-gradient-border {
+ border-radius: 20px;
+ corner-shape: squircle; // stylelint-disable-line property-no-unknown
+ mask:
+ url(#{$vrts-settings-icon-mask}) no-repeat center / 14px 14px,
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: add, exclude;
+ opacity: 1;
+ padding: 1px;
+
+ &::before {
+ background: #787c82;
+ transition: filter 0.2s;
+ }
+ }
+
+ &:hover,
+ &:focus-visible {
+
+ .vrts-gradient-border::before {
+ filter: brightness(0.8);
+ }
+ }
+
+ &[data-status="waiting"] {
+
+ .vrts-gradient-border {
+ padding: 1px;
+
+ &::before {
+ animation-play-state: running;
+ background: conic-gradient(from 0deg at 50% 50%, transparent 0%, #0894ff 25%, #c959dd 45%, #ff2e54 65%, #ff9004 85%, #0894ff 100%);
+ }
+ }
+ }
+
+ &[data-ai-seen="false"]:not([data-status="waiting"]) {
+
+ .vrts-gradient-border::before {
+ background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%);
+ }
+ }
+}
+
+/* stylelint-disable no-descending-specificity */
+.vrts-test-settings-modal {
+
+ .vrts-modal__content {
+ max-width: 450px;
+ }
+
+ .vrts-modal__content-inner {
+ padding-top: 15px;
+ }
+
+ textarea {
+ line-height: 1.7;
+ }
+
+ &__action {
+ margin-top: 20px;
+ display: flex;
+ align-items: center;
+ }
+
+ &__save {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ &__action-success {
+ color: #00a32a;
+ font-size: 0.75rem;
+ display: none;
+
+ &.is-active {
+ display: block;
+ }
+ }
+
+ .spinner {
+ margin: 0;
+ display: none;
+
+ &.is-active {
+ display: block;
+ }
+ }
+
+ &__ai-panel {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ border-radius: 6px;
+ border: 1px solid #e2e4e7;
+ position: relative;
+
+ .vrts-gradient-border {
+ padding: 1px;
+ inset: -1px;
+ transition-duration: 0.3s;
+ }
+
+ &[data-ai-state="results"]:hover:not(:has(.is-open)):not(.is-closing)
+ .vrts-gradient-border,
+ &[data-ai-state="results"].is-animating .vrts-gradient-border {
+ opacity: 1;
+
+ &::before {
+ animation-play-state: running;
+ }
+ }
+
+ // Hide toggle in loading and empty states.
+ &[data-ai-state="loading"],
+ &[data-ai-state="empty"] {
+
+ .vrts-test-settings-modal__ai-toggle {
+ display: none;
+ }
+ }
+
+ // Loading state: gradient text, dashed border.
+ &[data-ai-state="loading"] {
+ border-color: transparent;
+
+ .vrts-gradient-border {
+ opacity: 0.5;
+ }
+
+ .vrts-test-settings-modal__ai-summary {
+ cursor: default;
+ pointer-events: none;
+ background: linear-gradient(90deg, #0894ff 0%, #c959dd 25%, #ff2e54 50%, #ff9004 75%, #0894ff 100%);
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text; // stylelint-disable-line property-no-unknown
+ -webkit-text-fill-color: transparent; // stylelint-disable-line property-no-unknown
+ animation: vrts-gradient-text 3s linear infinite;
+ font-weight: 600;
+
+ &::before {
+ background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%);
+ }
+ }
+ }
+
+ // Empty state: muted.
+ &[data-ai-state="empty"] {
+ border-color: #e2e4e7;
+ border-style: dashed;
+
+ .vrts-gradient-border {
+ display: none;
+ }
+
+ .vrts-test-settings-modal__ai-summary {
+ cursor: default;
+ pointer-events: none;
+ color: #787878;
+
+ &::before {
+ background: #787878;
+ }
+ }
+ }
+ }
+
+ &__ai-summary {
+ all: unset;
+ box-sizing: border-box;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: #50575e;
+ padding: 0.75rem;
+ border-bottom: 1px solid transparent;
+ transition: border-color 0.2s;
+ font-weight: 500;
+ width: 100%;
+
+ &::before {
+ content: "";
+ display: block;
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%);
+ mask: url("#{$vrts-wand-icon-mask}") no-repeat center / contain;
+ }
+
+ &.is-open {
+ border-color: #e2e4e7;
+ }
+
+ &.is-open .vrts-test-settings-modal__ai-toggle svg {
+ transform: rotate(180deg);
+ }
+ }
+
+ &__ai-toggle {
+ color: #2271b1;
+ font-size: 0.75rem;
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+
+ svg {
+ width: 12px;
+ height: auto;
+ transition: transform 0.25s ease;
+ }
+ }
+
+ &__ai-details {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.25s ease;
+
+ &.is-open {
+ max-height: 300px;
+ }
+ }
+
+ &__ai-details-inner {
+ box-sizing: border-box;
+ padding: 1rem 0.75rem;
+ max-height: 300px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ &::-webkit-scrollbar {
+ width: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #dcdcde;
+ border-radius: 6px;
+ border: 2px solid #fff;
+ }
+ mask-image: linear-gradient(to bottom, #000 0%, #000 100%);
+
+ &.has-overflow {
+ mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 32px), transparent 100%);
+ }
+ }
+
+ &__ai-item {
+
+ code {
+ font-size: 0.675rem;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 3px 7px;
+ border-radius: 32px;
+ display: inline-block;
+ }
+
+ .description {
+ margin: 6px 0 0 5px;
+ font-size: 0.725rem;
+ }
+ }
+}
+/* stylelint-enable no-descending-specificity */
diff --git a/components/test-settings/index.php b/components/test-settings/index.php
new file mode 100644
index 0000000..92e047e
--- /dev/null
+++ b/components/test-settings/index.php
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ icon( 'hidden' ); ?>
+
+
+
+
+
+
diff --git a/components/test-settings/script.js b/components/test-settings/script.js
new file mode 100644
index 0000000..fc3a4af
--- /dev/null
+++ b/components/test-settings/script.js
@@ -0,0 +1,248 @@
+class VrtsTestSettings extends window.HTMLElement {
+ constructor() {
+ super();
+ this.aiSelectors = [];
+ this.resolveElements();
+ this.bindFunctions();
+ this.bindEvents();
+ }
+
+ resolveElements() {
+ this.$modal = this.querySelector( 'vrts-modal' );
+ this.$form = this.querySelector( '[data-vrts-test-settings-form]' );
+ this.$textarea = this.$form.querySelector(
+ '[name="hide_css_selectors"]'
+ );
+ this.$postIdInput = this.$form.querySelector( '[name="post_id"]' );
+ this.$testIdInput = this.$form.querySelector( '[name="test_id"]' );
+ this.$save = this.querySelector( '.vrts-test-settings-modal__save' );
+ this.$spinner = this.$save.querySelector( '.spinner' );
+ this.$success = this.querySelector(
+ '.vrts-test-settings-modal__action-success'
+ );
+ this.$aiPanel = this.querySelector(
+ '.vrts-test-settings-modal__ai-panel'
+ );
+ this.$aiLabel = this.querySelector( '[data-ai-label]' );
+ this.$aiToggle = this.querySelector( '[data-ai-toggle]' );
+ this.$aiDetails = this.querySelector(
+ '.vrts-test-settings-modal__ai-details'
+ );
+ }
+
+ bindFunctions() {
+ this.onButtonClick = this.onButtonClick.bind( this );
+ this.onFormSubmit = this.onFormSubmit.bind( this );
+ this.onModalClose = this.onModalClose.bind( this );
+ this.onToggleClick = this.onToggleClick.bind( this );
+ }
+
+ bindEvents() {
+ document.addEventListener( 'click', this.onButtonClick );
+ this.$form.addEventListener( 'submit', this.onFormSubmit );
+ this.$modal.addEventListener( 'hide', this.onModalClose );
+ this.$aiToggle.addEventListener( 'click', this.onToggleClick );
+ }
+
+ onButtonClick( e ) {
+ const button = e.target.closest( '.vrts-test-settings-button' );
+ if ( ! button ) {
+ return;
+ }
+
+ const postId = button.getAttribute( 'data-post-id' );
+ const testId = button.getAttribute( 'data-test-id' );
+ const testStatus = button.getAttribute( 'data-status' );
+ const hiddenData = document.getElementById( 'inline_' + testId );
+ const selectors =
+ hiddenData?.querySelector( '.hide_css_selectors' )?.textContent ||
+ '';
+ const aiSeen =
+ hiddenData?.querySelector( '.ai_selectors_seen' )?.textContent ||
+ '1';
+ const aiSelectorsRaw =
+ hiddenData?.querySelector( '.ai_selectors' )?.textContent || '';
+
+ this.$postIdInput.value = postId;
+ this.$testIdInput.value = testId;
+ this.$textarea.value = selectors;
+ this.$success.classList.remove( 'is-active' );
+ this.aiSelectors = [];
+ this.$aiDetails.classList.remove( 'is-open' );
+ this.$aiToggle.classList.remove( 'is-open' );
+ this.$aiToggle.setAttribute( 'aria-expanded', 'false' );
+ this.showAiPanel( aiSelectorsRaw, testStatus );
+
+ if ( aiSeen === '0' ) {
+ fetch(
+ `${ window.vrts_admin_vars.rest_url }/tests/${ testId }/ai-seen`,
+ {
+ method: 'POST',
+ headers: {
+ 'X-WP-Nonce': window.vrts_admin_vars.rest_nonce,
+ },
+ }
+ );
+
+ button.setAttribute( 'data-ai-seen', 'true' );
+ button.title = button.getAttribute( 'aria-label' );
+
+ const aiSeenEl = hiddenData?.querySelector( '.ai_selectors_seen' );
+ if ( aiSeenEl ) {
+ aiSeenEl.textContent = '1';
+ }
+ }
+ }
+
+ showAiPanel( aiSelectorsRaw, testStatus ) {
+ const hasAiMeta = aiSelectorsRaw !== '';
+ let newSelectors = [];
+ try {
+ newSelectors =
+ typeof aiSelectorsRaw === 'string' && aiSelectorsRaw
+ ? JSON.parse( aiSelectorsRaw )
+ : aiSelectorsRaw || [];
+ } catch ( err ) {
+ newSelectors = [];
+ }
+
+ const existingSet = new Set(
+ this.aiSelectors.map( ( item ) => item.selector )
+ );
+ newSelectors.forEach( ( item ) => {
+ if ( ! existingSet.has( item.selector ) ) {
+ this.aiSelectors.push( item );
+ }
+ } );
+
+ if ( this.aiSelectors.length > 0 ) {
+ this.$aiPanel.hidden = false;
+ this.$aiPanel.setAttribute( 'data-ai-state', 'results' );
+ this.renderAiSelectors();
+ this.updateAiLabel();
+ } else if ( testStatus === 'waiting' && ! hasAiMeta ) {
+ this.$aiPanel.hidden = false;
+ this.$aiPanel.setAttribute( 'data-ai-state', 'loading' );
+ this.$aiLabel.textContent = this.$aiPanel.dataset.textLoading;
+ } else if ( hasAiMeta ) {
+ this.$aiPanel.hidden = false;
+ this.$aiPanel.setAttribute( 'data-ai-state', 'empty' );
+ this.$aiLabel.textContent = this.$aiPanel.dataset.textEmpty;
+ } else {
+ this.$aiPanel.hidden = true;
+ }
+ }
+
+ renderAiSelectors() {
+ const items = this.aiSelectors
+ .map(
+ ( item ) =>
+ `
${ item.selector }${ item.reason }
`
+ )
+ .join( '' );
+ this.$aiDetails.innerHTML = `${ items }
`;
+ const inner = this.$aiDetails.querySelector(
+ '.vrts-test-settings-modal__ai-details-inner'
+ );
+ const updateFade = () => {
+ const hasOverflow = inner.scrollHeight > inner.clientHeight;
+ const atBottom =
+ inner.scrollTop + inner.clientHeight >= inner.scrollHeight - 2;
+ inner.classList.toggle( 'has-overflow', hasOverflow && ! atBottom );
+ };
+ inner.addEventListener( 'scroll', updateFade );
+ window.requestAnimationFrame( updateFade );
+ }
+
+ updateAiLabel() {
+ const total = this.aiSelectors.length;
+ const aiSet = new Set(
+ this.aiSelectors.map( ( item ) => item.selector )
+ );
+ const textareaSelectors = this.$textarea.value
+ .split( ',' )
+ .map( ( s ) => s.trim() )
+ .filter( Boolean );
+ const isExactMatch =
+ textareaSelectors.length === aiSet.size &&
+ textareaSelectors.every( ( s ) => aiSet.has( s ) );
+ const key = isExactMatch ? 'Added' : 'Suggested';
+ const plural = total === 1 ? 'Singular' : 'Plural';
+ const template = this.$aiPanel.dataset[ `text${ key }${ plural }` ];
+ this.$aiLabel.textContent = template.replace( '%d', total );
+ }
+
+ onFormSubmit( e ) {
+ e.preventDefault();
+ const formData = new window.FormData( this.$form );
+ const postId = formData.get( 'post_id' );
+ const testId = formData.get( 'test_id' );
+
+ this.$spinner.classList.add( 'is-active' );
+ this.$success.classList.remove( 'is-active' );
+
+ fetch( `${ window.vrts_admin_vars.rest_url }/tests/post/${ postId }`, {
+ method: 'PUT',
+ headers: {
+ 'X-WP-Nonce': window.vrts_admin_vars.rest_nonce,
+ },
+ body: new URLSearchParams( formData ),
+ } )
+ .then( ( response ) => response.json() )
+ .then( () => {
+ this.$spinner.classList.remove( 'is-active' );
+ this.$success.classList.add( 'is-active' );
+ setTimeout( () => {
+ this.$success.classList.remove( 'is-active' );
+ }, 5000 );
+
+ const hiddenData = document.getElementById(
+ 'inline_' + testId
+ );
+ if ( hiddenData ) {
+ const el = hiddenData.querySelector(
+ '.hide_css_selectors'
+ );
+ if ( el ) {
+ el.textContent = formData.get( 'hide_css_selectors' );
+ }
+ }
+ } );
+ }
+
+ onToggleClick() {
+ const isOpen = this.$aiDetails.classList.toggle( 'is-open' );
+ this.$aiToggle.classList.toggle( 'is-open' );
+ this.$aiToggle.setAttribute( 'aria-expanded', String( isOpen ) );
+
+ clearTimeout( this.animatingTimeout );
+ if ( isOpen ) {
+ this.$aiPanel.classList.add( 'is-animating' );
+ this.animatingTimeout = setTimeout( () => {
+ this.$aiPanel.classList.remove( 'is-animating' );
+ }, 300 );
+ } else {
+ this.$aiPanel.classList.add( 'is-closing' );
+ this.animatingTimeout = setTimeout( () => {
+ this.$aiPanel.classList.remove( 'is-closing' );
+ }, 300 );
+ }
+ }
+
+ onModalClose() {
+ this.$success.classList.remove( 'is-active' );
+ this.$aiDetails.classList.remove( 'is-open' );
+ this.$aiToggle.classList.remove( 'is-open' );
+ this.$aiToggle.setAttribute( 'aria-expanded', 'false' );
+ this.aiSelectors = [];
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener( 'click', this.onButtonClick );
+ this.$form?.removeEventListener( 'submit', this.onFormSubmit );
+ this.$modal?.removeEventListener( 'hide', this.onModalClose );
+ this.$aiToggle?.removeEventListener( 'click', this.onToggleClick );
+ }
+}
+
+window.customElements.define( 'vrts-test-settings', VrtsTestSettings );
diff --git a/components/tests-page/views/tests-page-list.php b/components/tests-page/views/tests-page-list.php
index 3993865..c6ae657 100644
--- a/components/tests-page/views/tests-page-list.php
+++ b/components/tests-page/views/tests-page-list.php
@@ -73,6 +73,8 @@ class="page-title-action button-secondary"
}
?>
+
+ component( 'test-settings' ); ?>
diff --git a/includes/core/class-plugin.php b/includes/core/class-plugin.php
index 2a91bb2..43cdd82 100644
--- a/includes/core/class-plugin.php
+++ b/includes/core/class-plugin.php
@@ -349,6 +349,10 @@ private function wp_kses_svg() {
'height' => true,
'version' => true,
'fill' => true,
+ 'color' => true,
+ 'stroke' => true,
+ 'stroke-width' => true,
+ 'stroke-linejoin' => true,
'viewbox' => true,
'xmlns' => true,
'aria-hidden' => true,
diff --git a/includes/list-tables/class-tests-list-table.php b/includes/list-tables/class-tests-list-table.php
index 4fef971..7634d20 100644
--- a/includes/list-tables/class-tests-list-table.php
+++ b/includes/list-tables/class-tests-list-table.php
@@ -142,9 +142,15 @@ public function column_post_title( $item ) {
$this->row_actions( $actions )
);
+ $has_ai_suggestions = ! empty( $item->parsed_meta['ai_selectors'] );
+ $ai_selectors_seen = $has_ai_suggestions && isset( $item->parsed_meta['ai_selectors_seen'] ) && false === $item->parsed_meta['ai_selectors_seen'] ? '0' : '1';
+ $ai_selectors_json = array_key_exists( 'ai_selectors', $item->parsed_meta ) ? esc_html( wp_json_encode( $item->parsed_meta['ai_selectors'] ) ) : '';
+
$quickedit_hidden_fields = "
$item->hide_css_selectors
+
$ai_selectors_seen
+
$ai_selectors_json
";
return $row_actions . $quickedit_hidden_fields;
@@ -323,6 +329,7 @@ public function prepare_items() {
* @param object|array $item The current item.
*/
public function single_row( $item ) {
+ $item->parsed_meta = ! empty( $item->meta ) ? maybe_unserialize( $item->meta ) : [];
$classes = 'iedit';
?>
@@ -403,11 +410,32 @@ public function inline_edit() {
private function render_column_status( $item ) {
$status_data = Test::get_status_data( $item );
+ $has_ai_suggestions = ! empty( $item->parsed_meta['ai_selectors'] );
+ $ai_seen = $has_ai_suggestions && isset( $item->parsed_meta['ai_selectors_seen'] ) && false === $item->parsed_meta['ai_selectors_seen'] ? 'false' : 'true';
+ $has_ai_meta = array_key_exists( 'ai_selectors', $item->parsed_meta );
+ $tooltip = 'false' === $ai_seen
+ ? esc_attr__( 'AI-optimized configuration available', 'visual-regression-tests' )
+ : esc_attr__( 'Test settings', 'visual-regression-tests' );
+
+ // Only show "waiting" status on button during initial creation (no AI meta yet).
+ $button_status = ( 'waiting' === $status_data['class'] && $has_ai_meta ) ? 'scheduled' : $status_data['class'];
+
+ $settings_button = sprintf(
+ '',
+ esc_attr( $item->post_id ),
+ esc_attr( $item->id ),
+ esc_attr( $button_status ),
+ esc_attr( $ai_seen ),
+ esc_attr__( 'Test settings', 'visual-regression-tests' ),
+ $tooltip
+ );
+
return sprintf(
- '',
+ '',
'vrts-testing-status--' . $status_data['class'],
$status_data['text'],
- $status_data['instructions']
+ $status_data['instructions'],
+ $settings_button
);
}
diff --git a/includes/models/class-test.php b/includes/models/class-test.php
index 1d54530..ae7e937 100644
--- a/includes/models/class-test.php
+++ b/includes/models/class-test.php
@@ -107,6 +107,7 @@ public static function get_items( $args = [], $return_count = false ) {
tests.next_run_date,
tests.is_running,
tests.hide_css_selectors,
+ tests.meta,
posts.post_title,
CASE
WHEN alerts.latest_id is not null THEN '6-has-alert'
@@ -222,7 +223,8 @@ public static function get_item( $id = 0 ) {
test.last_comparison_date,
test.next_run_date,
test.is_running,
- test.hide_css_selectors
+ test.hide_css_selectors,
+ test.meta
FROM $tests_table as test
LEFT JOIN (
SELECT MAX(id) as latest_id, post_id
@@ -707,6 +709,7 @@ public static function cast_values( $test ) {
$test->next_run_date = ! is_null( $test->next_run_date ) ? mysql2date( 'c', $test->next_run_date ) : null;
$test->last_comparison_date = ! is_null( $test->last_comparison_date ) ? mysql2date( 'c', $test->last_comparison_date ) : null;
$test->is_running = ! is_null( $test->is_running ) ? (bool) $test->is_running : null;
+ $test->meta = ! empty( $test->meta ) ? maybe_unserialize( $test->meta ) : [];
return $test;
}
@@ -871,7 +874,7 @@ public static function get_status_data( $test ) {
$instructions = esc_html__( 'Refresh page to see result', 'visual-regression-tests' );
break;
case 'scheduled':
- $class = 'waiting';
+ $class = 'running';
$text = esc_html__( 'Scheduled', 'visual-regression-tests' );
$next_run = Test_Run::get_next_scheduled_run();
if ( $next_run ) {
@@ -987,6 +990,94 @@ public static function get_screenshot_data( $test ) {
];
}
+ /**
+ * Get the raw meta array for a test.
+ *
+ * @param int $id Test ID.
+ *
+ * @return array
+ */
+ private static function get_raw_meta( $id ) {
+ global $wpdb;
+
+ $tests_table = Tests_Table::get_table_name();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
+ $raw = $wpdb->get_var(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
+ "SELECT meta FROM $tests_table WHERE id = %d",
+ $id
+ )
+ );
+
+ return ! empty( $raw ) ? maybe_unserialize( $raw ) : [];
+ }
+
+ /**
+ * Save the meta array for a test.
+ *
+ * @param int $id Test ID.
+ * @param array $meta Meta array.
+ *
+ * @return bool
+ */
+ private static function save_meta( $id, $meta ) {
+ global $wpdb;
+
+ $tests_table = Tests_Table::get_table_name();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
+ $result = $wpdb->update(
+ $tests_table,
+ [ 'meta' => ! empty( $meta ) ? maybe_serialize( $meta ) : null ],
+ [ 'id' => $id ]
+ );
+
+ return false !== $result;
+ }
+
+ /**
+ * Get a specific meta key from a test.
+ *
+ * @param int $id Test ID.
+ * @param string $key Meta key.
+ *
+ * @return mixed|null
+ */
+ public static function get_meta( $id, $key ) {
+ $meta = self::get_raw_meta( $id );
+ return isset( $meta[ $key ] ) ? $meta[ $key ] : null;
+ }
+
+ /**
+ * Set a specific meta key on a test.
+ *
+ * @param int $id Test ID.
+ * @param array $values Associative array of key => value pairs.
+ *
+ * @return bool
+ */
+ public static function set_meta( $id, $values ) {
+ $meta = self::get_raw_meta( $id );
+ $meta = array_merge( $meta, $values );
+ return self::save_meta( $id, $meta );
+ }
+
+ /**
+ * Delete a specific meta key from a test.
+ *
+ * @param int $id Test ID.
+ * @param string $key Meta key.
+ *
+ * @return bool
+ */
+ public static function delete_meta( $id, $key ) {
+ $meta = self::get_raw_meta( $id );
+ unset( $meta[ $key ] );
+ return self::save_meta( $id, $meta );
+ }
+
/**
* Get tests by service test ids.
*
diff --git a/includes/rest-api/class-rest-tests-controller.php b/includes/rest-api/class-rest-tests-controller.php
index 1a12965..e169bdb 100644
--- a/includes/rest-api/class-rest-tests-controller.php
+++ b/includes/rest-api/class-rest-tests-controller.php
@@ -66,6 +66,12 @@ public function register_routes() {
'callback' => [ $this, 'update_test_callback' ],
'permission_callback' => [ $this, 'user_can_create' ],
]);
+
+ register_rest_route($this->namespace, $this->resource_name . '/(?P\d+)/ai-seen', [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'ai_seen_callback' ],
+ 'permission_callback' => [ $this, 'user_can_create' ],
+ ]);
}
/**
@@ -174,6 +180,27 @@ public function update_test_callback( WP_REST_Request $request ) {
return rest_ensure_response( $error );
}
+ /**
+ * Marks AI selectors as seen for a test.
+ *
+ * @param WP_REST_Request $request Current request.
+ */
+ public function ai_seen_callback( WP_REST_Request $request ) {
+ $data = $request->get_params();
+ $test_id = $data['test_id'] ?? 0;
+
+ if ( 0 !== $test_id ) {
+ Test::set_meta( (int) $test_id, [ 'ai_selectors_seen' => true ] );
+ return rest_ensure_response( [ 'success' => true ], 200 );
+ }
+
+ $error = new WP_Error(
+ 'rest_ai_seen_failed',
+ esc_html__( 'Could not mark AI selectors as seen.', 'visual-regression-tests' ),
+ [ 'status' => 400 ] );
+ return rest_ensure_response( $error );
+ }
+
/**
* Checks if a given request has access to create items.
*
diff --git a/includes/services/class-test-service.php b/includes/services/class-test-service.php
index 0cfc79e..916165b 100644
--- a/includes/services/class-test-service.php
+++ b/includes/services/class-test-service.php
@@ -41,7 +41,7 @@ public function update_test_from_comparison( $alert_id, $test_id, $data ) {
$update_data,
[ 'service_test_id' => $test_id ]
);
- }
+ }//end if
}
/**
@@ -68,7 +68,45 @@ public function update_test_from_schedule( $test_id, $data ) {
],
[ 'service_test_id' => $test_id ]
);
- }
+
+ // Save AI selectors from service callback payload.
+ $post_id = Test::get_post_id_by_service_test_id( $test_id );
+ if ( $post_id ) {
+ $test = Test::get_item_by_post_id( $post_id );
+ if ( $test && array_key_exists( 'ai_selectors', $data ) && is_array( $data['ai_selectors'] ) ) {
+ $ai_selectors = array_values( array_filter( array_map(
+ function ( $entry ) {
+ if ( ! is_array( $entry ) || empty( $entry['selector'] ) ) {
+ return null;
+ }
+
+ return [
+ 'selector' => sanitize_text_field( $entry['selector'] ),
+ 'reason' => isset( $entry['reason'] ) ? sanitize_text_field( $entry['reason'] ) : '',
+ ];
+ },
+ $data['ai_selectors']
+ ) ) );
+
+ $has_ai_suggestions = ! empty( $ai_selectors );
+
+ Test::set_meta( $test->id, [
+ 'ai_selectors' => $ai_selectors,
+ 'ai_selectors_seen' => ! $has_ai_suggestions,
+ ] );
+
+ if ( $has_ai_suggestions ) {
+ // Apply only when user has not configured manual hide selectors yet.
+ if ( empty( $test->hide_css_selectors ) ) {
+ $selectors = implode( ', ', array_values( array_unique( array_column( $ai_selectors, 'selector' ) ) ) );
+ if ( ! empty( $selectors ) ) {
+ Test::save_hide_css_selectors( $test->id, $selectors );
+ }
+ }
+ }
+ }//end if
+ }//end if
+ }//end if
}
/**
diff --git a/includes/tables/class-tests-table.php b/includes/tables/class-tests-table.php
index 963dd54..b477e12 100644
--- a/includes/tables/class-tests-table.php
+++ b/includes/tables/class-tests-table.php
@@ -4,7 +4,7 @@
class Tests_Table {
- const DB_VERSION = '1.5';
+ const DB_VERSION = '1.6';
const TABLE_NAME = 'vrts_tests';
/**
@@ -59,6 +59,7 @@ public static function install_table() {
next_run_date datetime,
is_running boolean,
hide_css_selectors longtext,
+ meta longtext,
PRIMARY KEY (id)
) $charset_collate;";