+
diff --git a/test/e2e/_ahrefs-analytics-suite.ts b/test/e2e/_ahrefs-analytics-suite.ts
new file mode 100644
index 00000000..db7aa170
--- /dev/null
+++ b/test/e2e/_ahrefs-analytics-suite.ts
@@ -0,0 +1,234 @@
+import { getBrowser, url } from '@nuxt/test-utils/e2e'
+import { expect, it } from 'vitest'
+
+interface CapturedRequest {
+ method: string
+ url: string
+ postData: string | null
+ contentType: string | null
+}
+
+// Stand-in for Ahrefs's analytics.js. The real script bails out on
+// `localhost` and on `navigator.webdriver`, which makes deterministic
+// assertions impossible under Playwright + the test fixture. The stubs below
+// mirror the integration shape we care about (initial pageview POST +
+// history.pushState patch) and post to the *exact* endpoint each mode
+// resolves to in production:
+// CDN mode -> https://analytics.ahrefs.com/api/event
+// bundled mode -> /_scripts/p/analytics.ahrefs.com/api/event (the path
+// the replace-new-url-origin sdkPatch produces from the
+// real `new URL(currentScript.src).origin + "/api/event"`)
+// Splitting the two stubs (rather than reusing currentScript.src origin)
+// guarantees the bundled-mode test breaks if the AST patch ever stops being
+// applied to the bundle output, which is the contract this suite guards.
+function buildStubAnalyticsJs(endpoint: string): string {
+ return `
+;(function(){
+ var s = document.currentScript;
+ var endpoint = ${JSON.stringify(endpoint)};
+ var key = s ? s.getAttribute('data-key') : null;
+ function send(name) {
+ try {
+ var body = JSON.stringify({ n: name, u: window.location.href, k: key, t: document.title });
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', endpoint, true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.send(body);
+ } catch (e) {}
+ }
+ function instance() {
+ return { sendEvent: function(n) { send(n || 'custom'); } };
+ }
+ window.AhrefsAnalytics = instance();
+ send('pageview');
+ var origPush = history.pushState;
+ history.pushState = function() {
+ var r = origPush.apply(this, arguments);
+ send('pageview');
+ return r;
+ };
+ window.addEventListener('popstate', function() { send('pageview'); });
+})();
+`
+}
+
+const CDN_STUB = buildStubAnalyticsJs('https://analytics.ahrefs.com/api/event')
+const BUNDLED_PROXY_PATH = '/_scripts/p/analytics.ahrefs.com/api/event'
+
+// Stub /api/event so beacon assertions are deterministic on CI. The real
+// script ties the data-key to a registered domain and silently drops beacons
+// from unregistered origins (e.g. localhost) and from headless/webdriver
+// contexts. We also stub analytics.js itself with a minimal pageview-firing
+// implementation, used in both modes:
+// CDN mode -> intercept https://analytics.ahrefs.com/analytics.js
+// bundled mode -> intercept /_scripts/assets/* (script body is rewritten
+// at build time but still served as a local asset)
+// Beacons land at:
+// CDN mode -> https://analytics.ahrefs.com/api/event
+// bundled mode -> /_scripts/p/analytics.ahrefs.com/api/event (proxy path
+// produced by the replace-new-url-origin sdkPatch)
+async function newCapturePage(opts: { bundled: boolean }) {
+ const browser = await getBrowser()
+ const page = await browser.newPage()
+ const requests: CapturedRequest[] = []
+ // Match either endpoint shape so both modes flow through the same capture.
+ await page.route(/\/api\/event(?:\?|$)/, async (route) => {
+ const req = route.request()
+ requests.push({
+ method: req.method(),
+ url: req.url(),
+ postData: req.postData() ?? null,
+ contentType: req.headers()['content-type'] ?? null,
+ })
+ await route.fulfill({ status: 200, contentType: 'text/plain', body: '' })
+ })
+ // CDN mode: replace analytics.js with our stub before it reaches the page.
+ await page.route('**/analytics.ahrefs.com/analytics.js', async (route) => {
+ await route.fulfill({ status: 200, contentType: 'application/javascript', body: CDN_STUB })
+ })
+ // Bundled mode: the rewritten script is served from /_scripts/assets/*.js.
+ // We don't know the hashed filename, but the suite only loads one bundled
+ // SDK per fixture so any /_scripts/assets/*.js request is unambiguous. The
+ // stub posts to the proxy path the real AST-rewritten script would resolve
+ // to. We compute it relative to the page origin so the test stays portable.
+ if (opts.bundled) {
+ const bundledStub = buildStubAnalyticsJs(`${new URL(url('/')).origin}${BUNDLED_PROXY_PATH}`)
+ await page.route('**/_scripts/assets/*.js', async (route) => {
+ await route.fulfill({ status: 200, contentType: 'application/javascript', body: bundledStub })
+ })
+ }
+ return { page, requests }
+}
+
+async function waitFor(
+ predicate: () => boolean,
+ { timeoutMs = 10000, intervalMs = 50, message = 'condition' }: { timeoutMs?: number, intervalMs?: number, message?: string } = {},
+) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ if (predicate())
+ return
+ await new Promise(r => setTimeout(r, intervalMs))
+ }
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${message}`)
+}
+
+interface SuiteOptions {
+ bundled: boolean
+}
+
+function isExpectedUrl(reqUrl: string, bundled: boolean): boolean {
+ const u = new URL(reqUrl)
+ if (bundled) {
+ // Bundled mode contract: the replace-new-url-origin sdkPatch rewrites
+ // `new URL(currentScript.src).origin + "/api/event"` to the proxy path.
+ // Beacons must land on the local origin AND on the proxy pathname — if
+ // either part regresses, the integration silently drops user data.
+ return u.origin === new URL(url('/')).origin
+ && u.pathname === BUNDLED_PROXY_PATH
+ }
+ // CDN mode: the script is loaded from analytics.ahrefs.com so beacons go
+ // directly to that host.
+ return u.host === 'analytics.ahrefs.com' && u.pathname === '/api/event'
+}
+
+function assertBeaconShape(req: CapturedRequest, bundled: boolean) {
+ expect(req.method, `expected POST, got ${req.method} for ${req.url}`).toBe('POST')
+ expect(isExpectedUrl(req.url, bundled), `unexpected URL shape: ${req.url}`).toBe(true)
+ const body = req.postData ?? ''
+ expect(body.length, `expected non-empty beacon payload, got empty body for ${req.url}`).toBeGreaterThan(0)
+ // Stub posts JSON; if content-type advertises it, ensure body parses.
+ if (req.contentType && req.contentType.includes('json')) {
+ expect(() => JSON.parse(body), `expected JSON-parseable body for ${req.url}`).not.toThrow()
+ }
+}
+
+export function defineAhrefsAnalyticsSuite(opts: SuiteOptions) {
+ it('script tag points at the expected origin with data-key set', async () => {
+ // Wiring assertion: the
+
+
+
+
Ahrefs Web Analytics
+
+
+ status: {{ status }}
+
+
+
+
+
+
diff --git a/test/fixtures/ahrefs-analytics-cdn/tsconfig.json b/test/fixtures/ahrefs-analytics-cdn/tsconfig.json
new file mode 100644
index 00000000..4b34df15
--- /dev/null
+++ b/test/fixtures/ahrefs-analytics-cdn/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./.nuxt/tsconfig.json"
+}
diff --git a/test/fixtures/ahrefs-analytics/app.vue b/test/fixtures/ahrefs-analytics/app.vue
new file mode 100644
index 00000000..8f62b8bf
--- /dev/null
+++ b/test/fixtures/ahrefs-analytics/app.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/test/fixtures/ahrefs-analytics/nuxt.config.ts b/test/fixtures/ahrefs-analytics/nuxt.config.ts
new file mode 100644
index 00000000..65cc1dd2
--- /dev/null
+++ b/test/fixtures/ahrefs-analytics/nuxt.config.ts
@@ -0,0 +1,15 @@
+import { defineNuxtConfig } from 'nuxt/config'
+
+// Bundled fixture (default `bundle: true`, so the script is served from
+// /_scripts/assets/ after AST rewrite). The CDN fixture extends this one
+// and overrides only the bundle setting + the page composable calls.
+export default defineNuxtConfig({
+ modules: ['@nuxt/scripts'],
+ scripts: {
+ defaultScriptOptions: { trigger: 'onNuxtReady' },
+ registry: {
+ ahrefsAnalytics: { key: 'test-ahrefs-key' },
+ },
+ },
+ compatibilityDate: '2024-07-05',
+})
diff --git a/test/fixtures/ahrefs-analytics/package.json b/test/fixtures/ahrefs-analytics/package.json
new file mode 100644
index 00000000..b9826b34
--- /dev/null
+++ b/test/fixtures/ahrefs-analytics/package.json
@@ -0,0 +1,3 @@
+{
+ "private": true
+}
diff --git a/test/fixtures/ahrefs-analytics/pages/ahrefs.vue b/test/fixtures/ahrefs-analytics/pages/ahrefs.vue
new file mode 100644
index 00000000..5f558070
--- /dev/null
+++ b/test/fixtures/ahrefs-analytics/pages/ahrefs.vue
@@ -0,0 +1,34 @@
+
+
+
+