{"id":308935,"date":"2026-05-27T09:59:17","date_gmt":"2026-05-27T09:59:17","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/uplift-ab-testing\/"},"modified":"2026-05-27T09:58:52","modified_gmt":"2026-05-27T09:58:52","slug":"variolab-ab-testing","status":"publish","type":"plugin","link":"https:\/\/wordpress.org\/plugins\/variolab-ab-testing\/","author":5973490,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"0.15.0","stable_tag":"0.15.0","tested":"6.9.4","requires":"6.0","requires_php":"8.1","requires_plugins":null,"header_name":"Variolab \u2013 A\/B Testing","header_author":"Guillaume Ferrari","header_description":"Lightweight A\/B testing for pages: internal tracking, persistent-cookie 50\/50 split, GDPR-friendly. No third-party dependency.","assets_banners_color":"f8f7f3","last_updated":"2026-05-27 09:58:52","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"https:\/\/github.com\/lozit\/variolab","header_author_uri":"","rating":0,"author_block_rating":0,"active_installs":0,"downloads":11,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"0.15.0":{"tag":"0.15.0","author":"lozit","date":"2026-05-27 09:58:52"}},"upgrade_notice":[],"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3550427,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3550427,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3550427,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3550427,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["0.15.0"],"block_files":[],"assets_screenshots":{"screenshot-1.png":{"filename":"screenshot-1.png","revision":3550427,"resolution":"1","location":"assets","locale":"","width":1200,"height":1196},"screenshot-2.png":{"filename":"screenshot-2.png","revision":3550427,"resolution":"2","location":"assets","locale":"","width":1200,"height":818},"screenshot-3.png":{"filename":"screenshot-3.png","revision":3550427,"resolution":"3","location":"assets","locale":"","width":1200,"height":1382}},"screenshots":{"1":"A\/B Tests admin list \u2014 KPI strip (active tests, impressions, conversions, overall rate, winners shipped), status filter chips (All \/ Draft \/ Running \/ Paused \/ Ended), date range with 7d \/ 30d \/ All-time presets, experiments grouped by URL with per-variant stats + lift + confidence interval + significance badges, archived ended tests collapsed into a details panel, daily conversion-rate sparkline per URL with start\/end markers","2":"Import HTML page \u2014 drag-and-drop upload of .html \/ .htm \/ .zip files (extracted to wp-content\/uploads\/abtest-templates\/{slug}\/ with relative-asset URL rewriting), plus the Watch directory panel for IDE \/ SFTP \/ cloud-sync workflows","3":"Settings \u2014 privacy &amp; consent gate (GDPR), Google Analytics 4 Measurement Protocol integration, generic webhooks (Zapier \/ Make \/ Mixpanel \/ Segment \/ Slack \/ n8n), and REST API documentation with a copy-paste curl example"}},"plugin_section":[],"plugin_tags":[992,232,984,1589],"plugin_category":[36,55],"plugin_contributors":[264617],"plugin_business_model":[],"class_list":["post-308935","plugin","type-plugin","status-publish","hentry","plugin_tags-ab-testing","plugin_tags-analytics","plugin_tags-conversion","plugin_tags-split-testing","plugin_category-analytics","plugin_category-seo-and-marketing","plugin_contributors-lozit","plugin_committers-lozit"],"banners":{"banner":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/banner-772x250.png?rev=3550427","banner_2x":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/banner-1544x500.png?rev=3550427","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":false,"icon":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/icon-128x128.png?rev=3550427","icon_2x":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/icon-256x256.png?rev=3550427","generated":false},"screenshots":[{"src":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/screenshot-1.png?rev=3550427","caption":"A\/B Tests admin list \u2014 KPI strip (active tests, impressions, conversions, overall rate, winners shipped), status filter chips (All \/ Draft \/ Running \/ Paused \/ Ended), date range with 7d \/ 30d \/ All-time presets, experiments grouped by URL with per-variant stats + lift + confidence interval + significance badges, archived ended tests collapsed into a details panel, daily conversion-rate sparkline per URL with start\/end markers"},{"src":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/screenshot-2.png?rev=3550427","caption":"Import HTML page \u2014 drag-and-drop upload of .html \/ .htm \/ .zip files (extracted to wp-content\/uploads\/abtest-templates\/{slug}\/ with relative-asset URL rewriting), plus the Watch directory panel for IDE \/ SFTP \/ cloud-sync workflows"},{"src":"https:\/\/ps.w.org\/variolab-ab-testing\/assets\/screenshot-3.png?rev=3550427","caption":"Settings \u2014 privacy &amp; consent gate (GDPR), Google Analytics 4 Measurement Protocol integration, generic webhooks (Zapier \/ Make \/ Mixpanel \/ Segment \/ Slack \/ n8n), and REST API documentation with a copy-paste curl example"}],"raw_content":"<!--section=description-->\n<p>Run A\/B tests by pointing the plugin at two existing pages \u2014 one as the control (Variant A), one as the variant (Variant B). Visitors are split 50\/50 via a persistent cookie; once assigned, they always see the same variant.<\/p>\n\n<p>Tracking is fully internal: impressions and conversions land in a custom database table, and the wp-admin dashboard shows conversion rates, lift, and a basic statistical significance indicator (two-proportion z-test).<\/p>\n\n<p>Security-audited internally before every release (situated checklist + OWASP grid). See SECURITY.md on GitHub for the disclosure policy and the latest audit report (<code>docs\/security\/latest.md<\/code>).<\/p>\n\n<h4>Features<\/h4>\n\n<ul>\n<li>Page-level A\/B tests (entire page as variant \u2014 no Gutenberg surgery needed)<\/li>\n<li>Persistent cookie split (httponly, samesite=Lax)<\/li>\n<li>Internal tracking \u2014 no third-party dependency, no data leaving your site<\/li>\n<li>Conversion goals: URL visited or CSS selector clicked<\/li>\n<li>Auto bypass for logged-in editors and bots<\/li>\n<li>Two-proportion z-test for statistical significance<\/li>\n<li><code>abtest_event_logged<\/code> action hook ready for v2 GA4\/webhook integrations<\/li>\n<\/ul>\n\n<h3>Caching<\/h3>\n\n<p>A\/B testing breaks under page caching: the first variant served gets cached for everyone, all subsequent visitors get that same response, the 50\/50 split dies. The plugin handles most cases automatically.<\/p>\n\n<h4>What the plugin does automatically<\/h4>\n\n<ol>\n<li><strong>Sends <code>Cache-Control: no-store<\/code> headers<\/strong> on every page response under A\/B test. Respected by Cloudflare, Varnish, Kinsta edge cache, nginx page cache, and most server-level caches.<\/li>\n<li><strong>Hooks WP Rocket's <code>rocket_cache_reject_uri<\/code> filter<\/strong> when WP Rocket is detected \u2014 your test URLs are auto-added to the never-cache list.<\/li>\n<li><strong>Hooks LiteSpeed Cache's <code>litespeed_force_nocache_url<\/code> filter<\/strong> when LiteSpeed is detected \u2014 same idea.<\/li>\n<li><strong>Surfaces an admin notice<\/strong> when a cache plugin or known host (like Kinsta) is detected, with what to verify.<\/li>\n<\/ol>\n\n<h4>Hosting on Kinsta<\/h4>\n\n<p>Kinsta uses a two-layer cache (nginx server-cache + Cloudflare Enterprise edge cache). The plugin's no-store headers bypass both \u2014 but for 100% safety, also add your test URLs to <strong>MyKinsta \u2192 Tools \u2192 Cache \u2192 Cache Bypass<\/strong> as URL Patterns (e.g. <code>^\/promo\/$<\/code>). After publishing a new test, <strong>purge the Kinsta cache<\/strong> to flush any version cached before the experiment started.<\/p>\n\n<p>Verify it works by inspecting headers on your test URL:<\/p>\n\n<pre><code>curl -I https:\/\/yoursite.com\/promo\/\n<\/code><\/pre>\n\n<p>Look for <code>X-Kinsta-Cache: BYPASS<\/code> (or <code>MISS<\/code>). If you see <code>HIT<\/code>, you're getting the cached version and the split is broken \u2014 purge the cache.<\/p>\n\n<h4>Hosting on other CDNs \/ hosts<\/h4>\n\n<ul>\n<li><strong>Cloudflare APO<\/strong>: Cache-Control headers from origin override the cache. Should work out of the box. Verify with <code>curl -I<\/code> looking for <code>cf-cache-status: BYPASS<\/code> or <code>DYNAMIC<\/code>.<\/li>\n<li><strong>WP Engine<\/strong>: Add the test URLs to the \"Cache Exclusions\" list in your User Portal.<\/li>\n<li><strong>Pagely \/ Pantheon \/ Pressable<\/strong>: Cache-Control headers respected. Add manual URL exclusion in the host's panel for safety.<\/li>\n<\/ul>\n\n<h4>Plugins not auto-supported<\/h4>\n\n<p>For W3 Total Cache, WP Super Cache, WP Fastest Cache, and Cache Enabler \u2014 the plugin shows a notice but does not automatically exclude URLs (no clean public API). Manually add your test URLs to that plugin's cache exclusion list.<\/p>\n\n<h3>REST API<\/h3>\n\n<p>Pull stats programmatically from external tools (n8n, Make, Pipedream, dashboards).<\/p>\n\n<ul>\n<li>Endpoint: <code>GET \/wp-json\/abtest\/v1\/stats<\/code><\/li>\n<li>Auth: WP Application Passwords (Basic Auth). The user must have <code>manage_options<\/code>.<\/li>\n<li>Generate one in your WP profile \u2192 Application Passwords.<\/li>\n<\/ul>\n\n<p>Optional query params:<\/p>\n\n<ul>\n<li><code>url=\/promo\/<\/code> \u2014 filter to a single test URL.<\/li>\n<li><code>experiment_id=38<\/code> \u2014 fetch a single experiment by ID.<\/li>\n<li><code>status=running|paused|ended|draft<\/code> \u2014 filter by status.<\/li>\n<li><code>from=YYYY-MM-DD&amp;to=YYYY-MM-DD<\/code> \u2014 restrict event date range for the stats computation.<\/li>\n<li><code>breakdown=daily<\/code> \u2014 include per-day time series (for charting).<\/li>\n<\/ul>\n\n<p>Example:<\/p>\n\n<pre><code>curl -u 'admin:xxxx xxxx xxxx xxxx xxxx xxxx' 'https:\/\/yoursite.com\/wp-json\/abtest\/v1\/stats?status=running'\n<\/code><\/pre>\n\n<p>The response includes for each experiment: id, title, test_url, status, dates, control\/variant IDs, goal, and a stats block with A\/B impressions\/conversions\/rate, lift, p-value, significance, and 95% confidence interval bounds for both lift and absolute difference.<\/p>\n\n<h3>Privacy<\/h3>\n\n<p>The plugin stores no raw IP, no User-Agent, no email, no name, and no cross-site tracking identifier. The events table contains: experiment_id, variant, test_url, event_type, created_at, and a <code>visitor_hash<\/code> = first 16 hex chars (64 bits) of <code>sha256(IP + UA + wp_salt('auth'))<\/code> \u2014 non-reversible, single-site, salt-rotated, dedup-safe. Cookies are httponly, samesite=Lax, secure on HTTPS, value = a single letter (a\/b\/c\/d), 30-day TTL.<\/p>\n\n<p>A native WordPress privacy guide snippet is registered automatically \u2014 find it under Settings \u2192 Privacy \u2192 Policy Guide \u2192 Variolab \u2013 A\/B Testing to paste into your privacy policy.<\/p>\n\n<p>For consent-banner sites: enable \"Require consent\" in the plugin settings and wire your banner to the <code>abtest_visitor_has_consent<\/code> filter (return true to track, false\/null to block). Snippets for Complianz, CookieYes, and Cookiebot are in the README on GitHub.<\/p>\n\n<p>Right to erasure: because no reversible identifier is stored, individual deletion isn't possible. Use <code>TRUNCATE wp_abtest_events<\/code> to erase all A\/B testing data.<\/p>\n\n<h3>External services<\/h3>\n\n<p>This plugin connects to one external service, <strong>only when the site administrator opts in<\/strong> through the plugin's Settings \u2192 Google Analytics 4 panel by entering a Measurement ID and API Secret. With the GA4 integration disabled (default), no data leaves your site.<\/p>\n\n<h4>Google Analytics 4 (Measurement Protocol)<\/h4>\n\n<p>What it is and what it's used for: when the GA4 integration is enabled, the plugin forwards A\/B-test impression and conversion events to Google Analytics 4 via the Measurement Protocol, so the test results can be analyzed alongside your existing GA4 reports.<\/p>\n\n<p>What data is sent and when: on each impression and each conversion logged by the plugin, a single fire-and-forget HTTPS request is sent to <code>https:\/\/www.google-analytics.com\/mp\/collect<\/code> with a JSON payload containing:<\/p>\n\n<ul>\n<li><code>client_id<\/code> \u2014 the plugin's internal visitor hash (truncated salted SHA-256 of IP + User-Agent; never the raw IP or UA)<\/li>\n<li><code>events[].name<\/code> \u2014 <code>abtest_impression<\/code> or <code>abtest_conversion<\/code><\/li>\n<li><code>events[].params.experiment_id<\/code> \u2014 the WordPress post ID of the experiment<\/li>\n<li><code>events[].params.variant<\/code> \u2014 the variant served (<code>a<\/code>, <code>b<\/code>, <code>c<\/code>, or <code>d<\/code>)<\/li>\n<li><code>events[].params.test_url<\/code> \u2014 the URL path under test (e.g. <code>\/promo\/<\/code>)<\/li>\n<\/ul>\n\n<p>No raw IP address, User-Agent string, email, name, WordPress user ID, or page content is sent.<\/p>\n\n<p>Service provided by Google. Please review their terms and policies before enabling the integration:<\/p>\n\n<ul>\n<li>Google Analytics terms of service: https:\/\/marketingplatform.google.com\/about\/analytics\/terms\/us\/<\/li>\n<li>Google privacy policy: https:\/\/policies.google.com\/privacy<\/li>\n<\/ul>\n\n<!--section=installation-->\n<ol>\n<li>Upload the plugin folder to <code>\/wp-content\/plugins\/<\/code>.<\/li>\n<li>Activate <strong>Variolab \u2013 A\/B Testing<\/strong> through the Plugins menu.<\/li>\n<li>Go to <strong>A\/B Tests<\/strong> in the admin sidebar to create your first experiment.<\/li>\n<\/ol>\n\n<!--section=faq-->\n<dl>\n<dt id=\"how%20is%20a%20visitor%20assigned%20to%20a%20variant%3F\"><h3>How is a visitor assigned to a variant?<\/h3><\/dt>\n<dd><p>On their first visit to the control page, a cookie <code>abtest_{experiment_id}<\/code> is set with value <code>a<\/code> or <code>b<\/code>. Subsequent visits read that cookie \u2014 the visitor always sees the same variant.<\/p><\/dd>\n<dt id=\"will%20admins%20see%20the%20test%3F\"><h3>Will admins see the test?<\/h3><\/dt>\n<dd><p>No. Logged-in users with <code>edit_posts<\/code> capability are bypassed and always see the control. The admin bar shows a marker indicating which experiment is running on the page.<\/p><\/dd>\n<dt id=\"does%20it%20work%20with%20woocommerce%20%2F%20gutenberg%20blocks%3F\"><h3>Does it work with WooCommerce \/ Gutenberg blocks?<\/h3><\/dt>\n<dd><p>v1 only swaps the entire page (the variant must be a separate post). Block-level and product-level testing are on the roadmap.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>0.15.0<\/h4>\n\n<ul>\n<li><strong>List page redesign.<\/strong> New branded dashboard at A\/B Tests: header with Variolab brandline (icon + wordmark + version pill); 5-card KPI strip (Active tests \/ Impressions \/ Conversions \/ Overall rate \/ Winners shipped) driven by a new <code>Stats::overview_kpis()<\/code> aggregator; toolbar with 5 status chips (All \/ Draft \/ Running \/ Paused \/ Ended) + date range with 7d\/30d\/All-time presets; URL blocks rendered as cards with per-experiment 3-column CSS grid; ended experiments collapse into a native <code>&lt;details&gt;<\/code> per URL block. Cream canvas (#EFECE4) replaces the wp-admin gray across every plugin admin screen (scoped via <code>body.toplevel_page_abtest-experiments<\/code>).<\/li>\n<li><strong>Inline SVG sparklines, Chart.js dropped.<\/strong> Replaces the ~205 KB vendored Chart.js + the <code>assets\/js\/url-charts.js<\/code> wrapper with a ~200-LOC vanilla <code>list-interactions.js<\/code> that renders an SVG polyline per (experiment, variant) using a 12-hex rotating palette so the chart line color matches the variant tag color in the row above. Variant A renders solid, B\/C\/D dashed. Dashed light-gray vertical markers show each experiment's start + end dates with hover tooltip.<\/li>\n<li><strong>Shared brand shell across all admin pages.<\/strong> New <code>Admin::render_brand_header( $title )<\/code> helper applied to List \/ Edit \/ Settings \/ Import; the legacy form-table styles on Edit \/ Settings \/ Import are preserved by the dual <code>wrap vlab-page abtest-wrap<\/code> class so the form submission paths are unchanged.<\/li>\n<li><strong>Inter Tight + JetBrains Mono variable fonts bundled<\/strong> as WOFF2 with a Latin Unicode subset (~200 KB total via <code>pyftsubset<\/code>). SIL OFL 1.1 license files shipped alongside.<\/li>\n<li>New <code>?status_filter=all|draft|running|paused|ended<\/code> query arg replaces the old <code>?show=<\/code>. The legacy parameter is translated silently for one release so bookmarks keep working.<\/li>\n<li>New CSS architecture: <code>admin-tokens.css<\/code> (design tokens + <code>@font-face<\/code>, everywhere) \u2192 <code>admin-shell.css<\/code> (cream bg + brandline + buttons, everywhere) \u2192 <code>admin-list.css<\/code> (list-specific, list page only). The legacy <code>admin.css<\/code> is kept verbatim for the other pages.<\/li>\n<li>Internal naming preserved: <code>Abtest\\<\/code> PHP namespace, <code>abtest_*<\/code> hook \/ cookie \/ option \/ table prefixes, REST namespace <code>abtest\/v1<\/code>, custom table <code>wp_abtest_events<\/code>. No DB \/ cookie \/ option breaking change.<\/li>\n<\/ul>\n\n<h4>0.14.0<\/h4>\n\n<ul>\n<li><strong>wp.org Plugin Review round 2 \u2014 all findings addressed.<\/strong><\/li>\n<li><strong>Slug<\/strong> <code>variolab<\/code> \u2192 <code>variolab-ab-testing<\/code> (matches the slug wp.org reserved on resubmission). Text domain mass-updated across every <code>__()<\/code> \/ <code>_e()<\/code> call, main file <code>variolab.php<\/code> \u2192 <code>variolab-ab-testing.php<\/code>, <code>composer.json<\/code> \/ <code>package.json<\/code> package names, <code>phpcs.xml.dist<\/code> text-domain element + file ref, <code>tests\/Integration\/bootstrap.php<\/code> require path, <code>.github\/workflows\/{ci,release}.yml<\/code> build folder + zip filename + header-version grep.<\/li>\n<li><strong>HtmlImport hardening<\/strong>: zip extraction no longer writes <code>.html<\/code> \/ <code>.htm<\/code> \/ <code>.js<\/code> files to the uploads directory (wp.org policy: no code-bearing files in uploads even though the area itself is allowed). The main <code>index.html<\/code> is read directly from the zip into memory and stored in <code>post_content<\/code> instead \u2014 never touches disk. CSS, images, fonts continue to extract normally. Local JS the template referenced via <code>&lt;script src=\".\/bundle.js\"&gt;<\/code> will 404 at render time; admins can re-inject inline JS via the per-URL tracking-scripts feature.<\/li>\n<li><strong>Per-URL tracking scripts refactor<\/strong>: <code>UrlScripts::print_for_position()<\/code> now wraps each entry via <code>wp_print_inline_script_tag()<\/code> \u2014 the WP-blessed inline-script helper \u2014 instead of raw <code>echo<\/code>. The new <code>UrlScripts::parse_script_input()<\/code> silently strips <code>&lt;script ...&gt;<\/code> \/ <code>&lt;\/script&gt;<\/code> wrappers the admin pastes, extracting <code>src<\/code> \/ <code>async<\/code> \/ <code>defer<\/code> \/ <code>type<\/code> \/ <code>id<\/code> attributes for fidelity. 11 unit tests cover plain JS \/ single wrapper \/ multi-script degraded mode \/ orphan tags \/ boolean attributes.<\/li>\n<li><strong>CPT slug<\/strong> <code>ab_experiment<\/code> \u2192 <code>abtest_experiment<\/code> (wp.org requires \u22654-char prefixes; <code>ab_<\/code> was too short). <strong>Menu slug<\/strong> <code>ab-testing<\/code> \u2192 <code>abtest-experiments<\/code>. New idempotent migration <code>Plugin::pre_install_rename_post_type()<\/code> runs at upgrade (DB schema v1.3.0 \u2192 v1.4.0) renaming every existing <code>post_type<\/code> row in one statement before the new CPT registers on <code>init<\/code>. Uninstall handler accepts both new and legacy slugs so old installs still get cleaned up.<\/li>\n<li>Internal <code>Abtest\\<\/code> namespace, <code>abtest_*<\/code> hook \/ cookie \/ option \/ table prefixes (already 6-char), and REST namespace <code>abtest\/v1<\/code> stay untouched \u2014 no breaking change for existing data.<\/li>\n<\/ul>\n\n<h4>0.13.0<\/h4>\n\n<ul>\n<li><strong>Renamed plugin<\/strong> to <strong>Variolab \u2013 A\/B Testing<\/strong> (slug <code>variolab<\/code>). The wp.org Plugin Review Team flagged \"Uplift\" on two cumulative grounds: (1) it is the standard industry term for the A\/B-testing lift metric (non-distinctive \u2014 every VWO\/Statsig\/Insider\/etc. doc uses \"uplift\" to mean conversion-rate lift), and (2) UPLIFT\u00ae is a live USPTO trademark (Reg. 4973441, UPLIFT INC., San Francisco) in the same \"Advertising, Business &amp; Retail Services\" class as the plugin. Variolab is an invented term (vario + lab) with no wp.org \/ USPTO \/ SaaS hit at name-pick time.<\/li>\n<li>Coordinated multi-file change: plugin header display name + text domain (<code>uplift-ab-testing<\/code> \u2192 <code>variolab<\/code> everywhere \u2014 every <code>__()<\/code>\/<code>_e()<\/code> call across <code>includes\/<\/code>), main plugin file <code>uplift-ab-testing.php<\/code> \u2192 <code>variolab.php<\/code>, <code>composer.json<\/code>\/<code>package.json<\/code> package names, <code>phpcs.xml.dist<\/code> text-domain element + file ref + ruleset name, <code>tests\/Integration\/bootstrap.php<\/code> require path, <code>.github\/workflows\/{ci,release}.yml<\/code> build folder + zip filename + header-version grep.<\/li>\n<li><strong>Internal naming kept untouched<\/strong> (no DB \/ cookie \/ option \/ hook breaking change for existing installs): <code>Abtest\\<\/code> PHP namespace, <code>abtest_*<\/code> hook\/cookie prefixes, REST namespace <code>abtest\/v1<\/code>, custom table <code>wp_abtest_events<\/code>, option keys (<code>abtest_settings<\/code>, <code>abtest_db_version<\/code>).<\/li>\n<\/ul>\n\n<h4>0.12.0<\/h4>\n\n<ul>\n<li><strong>Renamed plugin<\/strong> to <strong>Uplift \u2013 A\/B Testing<\/strong> (slug <code>uplift-ab-testing<\/code>). The WordPress trademark guideline forbids the word \"WordPress\" in both the plugin display name and the slug \u2014 this rename closes the last remaining wp.org submission blocker.<\/li>\n<li>Coordinated multi-file change: plugin header, text domain (<code>uplift-ab-testing<\/code> everywhere \u2014 every <code>__()<\/code>\/<code>_e()<\/code> call across <code>includes\/<\/code>), main plugin file <code>ab-testing-wordpress.php<\/code> \u2192 <code>uplift-ab-testing.php<\/code>, <code>composer.json<\/code>\/<code>package.json<\/code> package names, <code>phpcs.xml.dist<\/code> text-domain element, <code>tests\/Integration\/bootstrap.php<\/code> require path, <code>release.yml<\/code> + <code>ci.yml<\/code> build paths and zip filename.<\/li>\n<li><strong>Internal naming kept untouched<\/strong> (no breaking change for existing installs): the <code>Abtest\\<\/code> PHP namespace, <code>abtest_*<\/code> hook prefixes, <code>abtest_*<\/code> cookies, REST namespace <code>abtest\/v1<\/code>, custom table <code>wp_abtest_events<\/code>, and option keys (<code>abtest_settings<\/code>, <code>abtest_db_version<\/code>) all stay as-is. They're internal \u2014 never visible to wp.org reviewers and never on a user URL.<\/li>\n<\/ul>\n\n<h4>0.11.3<\/h4>\n\n<ul>\n<li>WordPress.org compliance \u2014 final Plugin Check cleanup:\n\n<ul>\n<li><code>wp-tests-config.php<\/code>, <code>phpunit.xml*<\/code>, and <code>phpcs.xml*<\/code> are now excluded from the built plugin folder by both <code>release.yml<\/code> and <code>ci.yml<\/code>. They were leaking into the artifact and tripping <code>missing_direct_file_access_protection<\/code> (the test bootstrap doesn't and shouldn't have an <code>ABSPATH<\/code> guard).<\/li>\n<li>Replaced <code>languages\/.gitkeep<\/code> with <code>languages\/index.php<\/code> (the canonical \"Silence is golden\" pattern). <code>.gitkeep<\/code> was rejected as a hidden file by Plugin Check.<\/li>\n<li>Renamed two unprefixed locals in <code>templates\/blank-canvas.php<\/code> (<code>$insert_at<\/code> \u2192 <code>$abtest_insert_at<\/code>, <code>$body_close<\/code> \u2192 <code>$abtest_body_close<\/code>). Template files run in global scope, so unprefixed top-level vars trip <code>PrefixAllGlobals.NonPrefixedVariableFound<\/code>.<\/li>\n<\/ul><\/li>\n<li>Plugin Check on the built artifact is now green: 0 errors, 0 warnings.<\/li>\n<\/ul>\n\n<h4>0.11.2<\/h4>\n\n<ul>\n<li>WordPress.org compliance hardening (post-Plugin-Check first run):\n\n<ul>\n<li>Plugin Check CI now runs against the <strong>built<\/strong> plugin folder (mirroring <code>release.yml<\/code>'s rsync) instead of the raw repo, so dev-only files (<code>tests\/<\/code>, <code>.claude\/<\/code>, <code>.github\/<\/code>, <code>CLAUDE.md<\/code>, <code>composer.json<\/code>, etc.) no longer pollute the report. Cuts ~80% of the false-positive noise.<\/li>\n<li><code>ignore-codes<\/code> list added with one-line rationale per entry: custom-table direct queries, file-system ops on plugin-controlled paths, <code>mt_rand<\/code>\/<code>mt_srand<\/code> for variant picking, <code>meta_query<\/code> slow-query warnings, the <code>init<\/code> core-hook false positive.<\/li>\n<\/ul><\/li>\n<li>Removed <code>load_plugin_textdomain()<\/code> call: WordPress.org auto-loads translations for hosted plugins since WP 4.6 \u2014 manual loading is now discouraged. Text-domain header stays declared so JIT loading still works.<\/li>\n<li>Added empty <code>languages\/<\/code> folder (with a <code>.gitkeep<\/code> documenting why) to satisfy the <code>Domain Path: \/languages<\/code> plugin header \u2014 Plugin Check (and wp.org reviewers) flag the header when the folder doesn't exist.<\/li>\n<\/ul>\n\n<h4>0.11.1<\/h4>\n\n<ul>\n<li>WordPress.org compliance: Chart.js (used to render the per-URL conversion-rate timeline on the admin list view) is no longer loaded from the jsdelivr CDN \u2014 it's now bundled under <code>assets\/js\/vendor\/chart.umd.min.js<\/code>. This satisfies the wp.org plugin guideline #5 \"Trying to remotely load code\". MIT license attribution + update instructions are documented in <code>assets\/js\/vendor\/README.md<\/code>.<\/li>\n<li>New CI step: WordPress's official <code>plugin-check-action<\/code> runs on every push to <code>main<\/code> and PR. Same automated checks as the wp.org reviewers (plugin headers, i18n, late escaping, deprecated APIs, internationalization). Any future regression that would be flagged at submission time is caught at push time instead.<\/li>\n<\/ul>\n\n<h4>0.11.0<\/h4>\n\n<ul>\n<li>New: <strong>per-URL no-index toggle<\/strong>. A new \"SEO\" row on the experiment edit form lets you mark any test URL as no-index. When checked, every visit to that URL emits both a <code>&lt;meta name=\"robots\" content=\"noindex,nofollow\"&gt;<\/code> tag and a matching <code>X-Robots-Tag<\/code> HTTP header \u2014 regardless of which experiment is currently running. Recommended for landing pages dedicated to paid traffic, or any URL where you don't want both A\/B variants to compete in search results.<\/li>\n<li>The setting is URL-scoped (stored in a new <code>abtest_url_settings<\/code> option keyed by URL path) so every experiment that lands on the same URL inherits it. Future URL-scoped flags can plug into the same store.<\/li>\n<li>New <code>Abtest\\UrlSettings<\/code> helper class with 7 unit tests covering normalization, default pruning, and per-URL independence.<\/li>\n<\/ul>\n\n<h4>0.10.1<\/h4>\n\n<ul>\n<li>i18n cleanup: every committed file is now in English. The plugin's user-facing strings (HelpTabs, StatsExplain) ship as English source so the standard WordPress translation pipeline (<code>.pot<\/code> \/ <code>.po<\/code>) can produce localized versions later. Audit reports, todo, slash commands, internal rules, lessons-learned all translated. CLAUDE.md adds an explicit \"English only in the repo\" rule to prevent regressions.<\/li>\n<\/ul>\n\n<h4>0.10.0<\/h4>\n\n<ul>\n<li>New: <strong>WordPress contextual help<\/strong> on the A\/B Tests screens. Click \"Help\" at the top-right of any A\/B Tests page to get 4 didactic tabs: Quick start, Stats explained (p-value \/ \u03b1 \/ \"no winner\" reasons), Multi-variant (Bonferroni correction), Privacy &amp; GDPR. Designed for non-statisticians installing the plugin for the first time.<\/li>\n<li>New: <strong>contextual tooltip on the \"No winner\" badge<\/strong> in the experiments list. Hover (or screen-reader-focus) the badge to see WHY this experiment doesn't have a winner \u2014 the explanation auto-detects between: \"too early\" (running &lt; 14 days), \"sample too small\" (&lt; 200 imp\/variant), \"borderline\" (p just above \u03b1), \"genuine null result\" (rates within \u00b115%), or generic \"keep the test running\". Powered by a new pure-function helper <code>Abtest\\Admin\\StatsExplain<\/code> with 8 unit tests covering each branch.<\/li>\n<\/ul>\n\n<h4>0.9.3<\/h4>\n\n<ul>\n<li>PHPCS WordPress Coding Standards : repaid the 1083-finding cosmetic dette. The codebase is now fully WPCS-clean and the GitHub Actions <code>lint<\/code> job is BLOCKING (was <code>continue-on-error<\/code>). Any new code that violates the ruleset fails the build.<\/li>\n<li>phpcs.xml.dist relaxed for modern PHP 8.1+ idioms : short array syntax <code>[]<\/code>, short ternary <code>?:<\/code>, alignment, and trivial-method docblocks no longer enforced. All Security \/ SQL \/ i18n \/ capability \/ nonce sniffs remain strict.<\/li>\n<li>All <code>phpcs:ignore<\/code> annotations on the codebase carry a one-line justification (why the rule is suppressed at this site).<\/li>\n<li>Bonus i18n fixes : added missing <code>translators:<\/code> comments on all <code>_n()<\/code> \/ <code>__()<\/code> calls with placeholders so the <code>.pot<\/code> file can guide translators.<\/li>\n<li>Bonus naming fix : renamed <code>Autoload::load($class)<\/code> to <code>Autoload::load($class_name)<\/code> since <code>class<\/code> is a PHP reserved keyword as a parameter name.<\/li>\n<\/ul>\n\n<h4>0.9.2<\/h4>\n\n<ul>\n<li>Security hardening sweep \u2014 all open findings from the v0.9.1 audit closed.<\/li>\n<li>HTML upload now performs a real MIME check (<code>wp_check_filetype_and_ext()<\/code>) on top of the extension allowlist \u2014 for <code>.zip<\/code> this catches a PHP file disguised as a zip via magic-byte mismatch.<\/li>\n<li>Webhook URLs are now refused if they don't start with <code>http:\/\/<\/code> or <code>https:\/\/<\/code> (anti-SSRF basic \u2014 blocks <code>gopher:\/\/<\/code>, <code>ftp:\/\/<\/code>, <code>webcal:\/\/<\/code>, etc. that <code>esc_url_raw()<\/code> would otherwise accept).<\/li>\n<li>Public REST endpoint <code>\/abtest\/v1\/convert<\/code> now rate-limits each visitor IP to 60 conversions per minute (filterable via <code>abtest_convert_rate_limit_per_min<\/code>). Returns HTTP 429 when exceeded. Prevents distributed flood from biasing experiment statistics.<\/li>\n<li>PSR-4 autoloader rejects class names containing <code>..<\/code> defensively (anti-traversal hardening).<\/li>\n<li><code>.gitignore<\/code> extended with <code>.env<\/code>, <code>.env.*<\/code>, <code>wp-tests-config.php<\/code>, <code>*.local.php<\/code>, <code>*.key<\/code>, <code>*.pem<\/code>, <code>*.p12<\/code>, <code>secrets.json<\/code> (preventive \u2014 none of these files exist today).<\/li>\n<li>PHPCS false-positive annotations added on <code>file_get_contents()<\/code> calls reading local files (4 spots) and on the intentional 5-minute Watcher cron interval.<\/li>\n<\/ul>\n\n<h4>0.9.1<\/h4>\n\n<ul>\n<li>Security hardening (post-audit): outbound webhook POSTs now pass <code>'sslverify' =&gt; true<\/code> explicitly so a third-party <code>http_request_args<\/code> filter can't silently downgrade SSL verification. Aligns with the explicit setting already in the GA4 integration.<\/li>\n<li>HTML import error message corrected \u2014 used to say \"Only .html and .htm files are accepted\" even though .zip has been accepted since v0.7.0. Message now generated from the live ALLOWED_EXTS constant and reports the rejected extension.<\/li>\n<\/ul>\n\n<h4>0.9.0<\/h4>\n\n<ul>\n<li>Multilingual support (WPML \/ Polylang): a single experiment with <code>test_url = \/promo\/<\/code> now matches <code>\/fr\/promo\/<\/code>, <code>\/en\/promo\/<\/code>, <code>\/de\/promo\/<\/code>, etc. The bundled <code>MultiLanguage<\/code> helper auto-detects WPML\/Polylang and strips the language prefix from request paths before matching. Compound slugs (<code>pt-br<\/code>, <code>en-us<\/code>) supported. Mid-path occurrences of a language slug (e.g. <code>\/blog\/fr\/x\/<\/code>) are NOT stripped \u2014 only true URL prefixes.<\/li>\n<li>New filter <code>abtest_request_path<\/code> for custom multilingual setups: receives the normalized request path, returns whatever you want the matcher to see. Documented in README.<\/li>\n<li>Filter is opt-out for non-default behavior: <code>remove_filter('abtest_request_path', [\\Abtest\\MultiLanguage::class, 'strip_language_prefix'])<\/code>.<\/li>\n<\/ul>\n\n<h4>0.8.2<\/h4>\n\n<ul>\n<li>RGPD data minimization: visitor_hash is now stored as 16 hex chars (64 bits) instead of 64 chars (256 bits). Birthday-collision probability stays under 3e-8 even at 1M visitors per experiment, dedup integrity preserved, and the smaller surface harder to brute-force against IP+UA rainbow tables. DB schema bumped to v1.3.0 \u2014 migration auto-truncates existing visitor_hash values via SUBSTRING before the column ALTER (idempotent, runs before dbDelta).<\/li>\n<li>Privacy policy guide text updated to describe the 64-bit truncated hash.<\/li>\n<\/ul>\n\n<h4>0.8.1<\/h4>\n\n<ul>\n<li>Tested up to WordPress 6.9 (was 6.5). Local dev env (wp-env) and the wp-phpunit test suite both bumped to 6.9.4.<\/li>\n<li>Fixed PHP notice on WP 6.7+ (\"_load_textdomain_just_in_time was called incorrectly\") \u2014 load_plugin_textdomain now runs on <code>init<\/code> priority 0 instead of <code>plugins_loaded<\/code>.<\/li>\n<li>Performance: <code>GET \/wp-json\/abtest\/v1\/stats<\/code> now runs a single batched SQL query for N experiments instead of N individual queries (N+1 \u2192 1). New public <code>Stats::raw_counts_for_experiments()<\/code> powers both the REST endpoint and the admin list \u2014 same SQL path everywhere.<\/li>\n<\/ul>\n\n<h4>0.8.0<\/h4>\n\n<ul>\n<li>Privacy &amp; consent gating (GDPR): new \"Require consent\" toggle in Settings \u2014 when on, the plugin sets no cookie and logs no event until the <code>abtest_visitor_has_consent<\/code> filter returns true. Without consent, visitors silently see Variant A (same path as out-of-target). Off by default, no breaking change.<\/li>\n<li>Native WordPress privacy guide content registered via <code>wp_add_privacy_policy_content()<\/code> \u2014 find it under Settings \u2192 Privacy \u2192 Policy Guide \u2192 Variolab \u2013 A\/B Testing, ready to paste into your privacy policy.<\/li>\n<li>README now has a Privacy &amp; GDPR section with copy-paste filter snippets for Complianz, CookieYes, and Cookiebot.<\/li>\n<li>New <code>Consent<\/code> helper class + 5 unit tests covering the 4 gate states (off, on+true, on+false, on+null\/missing filter).<\/li>\n<\/ul>\n\n<h4>0.7.0<\/h4>\n\n<ul>\n<li>HTML import accepts <code>.zip<\/code> archives \u2014 extracts CSS\/JS\/images to <code>wp-content\/uploads\/abtest-templates\/{slug}\/<\/code>, rewrites relative asset URLs in the HTML so the page renders with full styling (security: extension allowlist + path-traversal guard).<\/li>\n<li>Watch directory: drop or edit <code>index.html<\/code> files in <code>wp-content\/uploads\/abtest-templates\/{slug}\/<\/code> from your IDE, SFTP, or cloud sync \u2014 WP-Cron syncs changed files into pages every 5 minutes (or click \"Scan now\" in the Import HTML page). Hash-based change detection skips unchanged files.<\/li>\n<li>URL targeting now matches query strings (subset semantics): <code>test_url = \/promo\/?campaign=fb<\/code> matches visitor URL <code>\/promo\/?campaign=fb&amp;utm_source=email<\/code>. Param order is canonicalized.<\/li>\n<li>URL targeting accepts Unicode paths: <code>test_url = \/promotion-\u00e9t\u00e9\/<\/code> matches both the raw and percent-encoded request paths.<\/li>\n<li>Validation regex updated to accept Unicode lowercase letters\/digits (was ASCII-only). HTML form <code>pattern=<\/code> constraint removed accordingly.<\/li>\n<\/ul>\n\n<h4>0.6.1<\/h4>\n\n<ul>\n<li>Targeting refinement: out-of-target visitors now silently see the baseline (Variant A) instead of getting a 404 on custom URLs. They are NOT tracked \u2014 no cookie set, no impression logged, no conversion script enqueued. Out-of-target visitors on URLs that override an existing public page still fall through to that original page (unchanged).<\/li>\n<li>The point: ad-paid traffic from outside your target audience (geo or device) doesn't waste clicks on 404s and doesn't pollute your test stats either.<\/li>\n<\/ul>\n\n<h4>0.6.0<\/h4>\n\n<ul>\n<li>Targeting by device (mobile \/ tablet \/ desktop) and country (ISO codes).<\/li>\n<li>HTML import: drag-and-drop dropzone + sandboxed iframe preview before submit.<\/li>\n<li>Visitor device classified from User-Agent; country pulled from Cloudflare\/Kinsta <code>CF-IPCountry<\/code> header (and similar X-* headers), with a <code>abtest_visitor_country<\/code> filter for custom geo plugins.<\/li>\n<li>Targeting check happens server-side before any cookie is set or impression logged \u2014 out-of-target visitors fall through (no variant assigned).<\/li>\n<li>Admin\/bot bypass mode is exempt from targeting so preview is independent of the previewer's device\/country.<\/li>\n<\/ul>\n\n<h4>0.5.0<\/h4>\n\n<ul>\n<li>Multi-variant tests up to 4 variants (A\/B\/C\/D) with equal split (1\/N each).<\/li>\n<li>Stats engine supports pairwise comparisons vs baseline + Bonferroni-corrected alpha.<\/li>\n<li>Schema migration v1.2.0 \u2014 auto-backfills <code>_abtest_variants<\/code> from legacy control_id\/variant_id pair.<\/li>\n<li>Admin form: dynamic variants list (add\/remove rows up to MAX_VARIANTS).<\/li>\n<li>Experiments list: variants stacked vertically per row with lift + 95% CI vs baseline.<\/li>\n<li>CSV export extended with per-variant + pairwise columns.<\/li>\n<li>REST API stats response now includes <code>variants<\/code>, <code>comparisons<\/code>, <code>baseline<\/code>, <code>best<\/code>, <code>alpha<\/code>.<\/li>\n<li>Back-compat: legacy <code>control_id<\/code>\/<code>variant_id<\/code> accessors and meta still work; legacy A\/B keys still in compute() output.<\/li>\n<\/ul>\n\n<h4>0.4.0<\/h4>\n\n<ul>\n<li>URL-decoupled experiments \u2014 <code>test_url<\/code> independent from variant pages.<\/li>\n<li>State machine (DRAFT \u2192 RUNNING \u2192 PAUSED\/ENDED) with Resume = duplicate semantics.<\/li>\n<li>Baseline mode (Variant B optional) and auto-downgrade on URL conflict.<\/li>\n<li>Replace running atomic swap action.<\/li>\n<li>HTML import \u2192 Blank Canvas template (zero WP wrapper).<\/li>\n<li>Per-URL tracking scripts (Adwords, FB Pixel, Lemlist, etc.).<\/li>\n<li>Cache bypass (universal Cache-Control headers + WP Rocket + LiteSpeed + Kinsta detection).<\/li>\n<li>Google Analytics 4 integration (Measurement Protocol).<\/li>\n<li>Generic webhook integration (Zapier, Mixpanel, Segment, Slack, n8n) with HMAC.<\/li>\n<li>REST API GET \/wp-json\/abtest\/v1\/stats with Application Password auth.<\/li>\n<li>95% confidence interval for the lift, date range filter, Chart.js timeline.<\/li>\n<li>GitHub Actions CI (PHP 8.1\/8.2\/8.3 matrix) + release workflow + Dependabot.<\/li>\n<\/ul>\n\n<h4>0.1.0<\/h4>\n\n<ul>\n<li>Initial MVP \u2014 page-level A\/B tests, internal tracking, cookie split, basic stats.<\/li>\n<\/ul>","raw_excerpt":"Lightweight A\/B testing for pages: internal tracking, persistent-cookie 50\/50 split, GDPR-friendly. No third-party dependency.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/308935","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=308935"}],"author":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/lozit"}],"wp:attachment":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=308935"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=308935"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=308935"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=308935"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=308935"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=308935"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}