diff --git a/Website/scripts/check-site.js b/Website/scripts/check-site.js
index 1eb5495..da9059e 100644
--- a/Website/scripts/check-site.js
+++ b/Website/scripts/check-site.js
@@ -6,6 +6,7 @@ import { parse as parseHtml } from 'parse5';
import { parse as parseJavaScript } from 'acorn';
export const CSS_BUDGET = 2500;
+const AUTHORIZED_GOOGLE_TAG = 'https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX';
async function filesUnder(directory) {
const entries = await readdir(directory, { withFileTypes: true });
@@ -143,7 +144,7 @@ export async function auditSite(directory, options = {}) {
}
if (node.tagName === 'script' && attribute(node, 'type') !== 'application/ld+json') {
const src = attribute(node, 'src');
- if (src && new URL(src, url).origin !== configured.origin) errors.push(`${relative}: external JavaScript could inject unbudgeted styles`);
+ if (src && new URL(src, url).origin !== configured.origin && src !== AUTHORIZED_GOOGLE_TAG) errors.push(`${relative}: external JavaScript could inject unbudgeted styles`);
if (!src && content(node).trim()) errors.push(...cssInjectionErrors(content(node), relative));
}
if (node.tagName === 'link' && attribute(node, 'hreflang')) {
diff --git a/Website/src/_includes/layouts/base.njk b/Website/src/_includes/layouts/base.njk
index eab5df7..83373d7 100644
--- a/Website/src/_includes/layouts/base.njk
+++ b/Website/src/_includes/layouts/base.njk
@@ -1,6 +1,15 @@
+
+
+
{{ title }} · {{ page.url | section | upper }} · RestClient.Net
diff --git a/Website/tests-node/site-guards.test.js b/Website/tests-node/site-guards.test.js
index 0a0ea54..480bec2 100644
--- a/Website/tests-node/site-guards.test.js
+++ b/Website/tests-node/site-guards.test.js
@@ -102,6 +102,63 @@ test('external scripts cannot outsource hidden CSS', async t => {
assert.match((await site.run()).errors.join('\n'), /external JavaScript/);
});
+test('the exact user-authorized Google tag works on root and prefixed deployments', async t => {
+ for (const prefix of ['/', '/RestClient.Net/']) {
+ const site = await fixture(t, { prefix });
+ await site.write('index.html', site.html('Docs
', `
+
+`));
+ const report = await site.run();
+ assert.deepEqual(report.errors, []);
+ assert.equal(report.cssBytes, Buffer.byteLength('body{color:#123}', 'utf8'));
+ assert.equal(report.pageCount, 1);
+ }
+});
+
+test('Google tag exception rejects every altered origin, path, measurement ID, and query', async t => {
+ const site = await fixture(t);
+ for (const src of [
+ 'http://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX',
+ '//www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX',
+ 'https://googletagmanager.com/gtag/js?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com.evil.test/gtag/js?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com@evil.test/gtag/js?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com:444/gtag/js?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com/gtm.js?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com/gtag/js/extra?id=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com/gtag/js?id=G-OTHER',
+ 'https://www.googletagmanager.com/gtag/js',
+ 'https://www.googletagmanager.com/gtag/js?other=G-PGDS00DBRX',
+ 'https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX&extra=1',
+ 'https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX&id=G-OTHER',
+ 'https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX#extra',
+ 'https://www.googletagmanager.com/gtag/js?id=%47-PGDS00DBRX',
+ ]) {
+ await site.write('index.html', site.html('Docs
', ``));
+ const report = await site.run();
+ assert.ok(report.errors.some(error => error.includes('external JavaScript')), src);
+ assert.equal(report.cssBytes, Buffer.byteLength('body{color:#123}', 'utf8'));
+ }
+});
+
+test('authorized Google tag does not exempt any CSS or other external scripts', async t => {
+ const site = await fixture(t, { stylesheet: ' '.repeat(CSS_BUDGET + 1) });
+ await site.write('index.html', site.html('Docs
', `
+
+
+`));
+ const errors = (await site.run()).errors.join('\n');
+ assert.match(errors, /CSS budget exceeded/);
+ assert.match(errors, /inline CSS bypasses/);
+ assert.match(errors, /external or embedded stylesheet/);
+ assert.match(errors, /external JavaScript/);
+});
+
test('SVG assets cannot conceal extra CSS outside the shared budget', async t => {
const site = await fixture(t);
for (const svg of ['', '']) {
diff --git a/Website/tests/analytics.test.js b/Website/tests/analytics.test.js
new file mode 100644
index 0000000..aac4e85
--- /dev/null
+++ b/Website/tests/analytics.test.js
@@ -0,0 +1,39 @@
+import { test, expect, expectAnalytics, analyticsScriptUrl } from './fixtures.js';
+
+for (const [language, routes] of [
+ ['English', ['/', '/docs/basic-usage/', '/blog/introducing-restclient/', '/api/reference/restclient-net-httpclientextensions/', '/examples/']],
+ ['Chinese', ['/zh/', '/zh/docs/basic-usage/', '/zh/blog/introducing-restclient/', '/zh/api/httpclient-extensions/', '/zh/examples/']],
+]) {
+ test(`${language} page families queue the provided Google Analytics configuration once`, async ({ page }) => {
+ const requests = [];
+ page.on('request', request => { if (request.url() === analyticsScriptUrl) requests.push(request.url()); });
+ for (const route of routes) {
+ const response = await page.goto(route);
+ expect(response?.status()).toBe(200);
+ await expect(page.locator('html')).toHaveAttribute('lang', language === 'English' ? 'en' : 'zh');
+ await expectAnalytics(page);
+ }
+ expect(requests).toHaveLength(routes.length);
+ });
+}
+
+test('link navigation and reload start one fresh analytics queue per document', async ({ page }) => {
+ await page.goto('/');
+ await expectAnalytics(page);
+ await page.evaluate(() => { window.__analyticsTestDocument = 'initial'; });
+
+ await page.getByRole('navigation', { name: 'Main navigation', exact: true }).getByRole('link', { name: 'Docs', exact: true }).click();
+ await expect(page).toHaveURL(/\/docs\/$/);
+ expect(await page.evaluate(() => window.__analyticsTestDocument)).toBeUndefined();
+ await expectAnalytics(page);
+ await page.evaluate(() => { window.__analyticsTestDocument = 'before-reload'; });
+
+ const response = await page.reload();
+ expect(response?.status()).toBe(200);
+ expect(await page.evaluate(() => window.__analyticsTestDocument)).toBeUndefined();
+ await expectAnalytics(page);
+
+ await page.getByRole('navigation', { name: 'Main navigation', exact: true }).getByRole('link', { name: 'API', exact: true }).click();
+ await expect(page).toHaveURL(/\/api\/$/);
+ await expectAnalytics(page);
+});
diff --git a/Website/tests/fixtures.js b/Website/tests/fixtures.js
index 45e7d36..4a6faa8 100644
--- a/Website/tests/fixtures.js
+++ b/Website/tests/fixtures.js
@@ -1,13 +1,47 @@
import { test as base, expect } from '@playwright/test';
+export const analyticsId = 'G-PGDS00DBRX';
+export const analyticsScriptUrl = `https://www.googletagmanager.com/gtag/js?id=${analyticsId}`;
+
+export async function expectAnalytics(page) {
+ const loader = page.locator(`head script[src="${analyticsScriptUrl}"]`);
+ await expect(loader, 'Each HTML page loads the requested Google tag once in its head').toHaveCount(1);
+ await expect(loader).toHaveAttribute('async', '');
+ await expect(page.locator('script[src*="googletagmanager.com"]')).toHaveCount(1);
+ const queue = await page.evaluate(() => {
+ const calls = Array.isArray(window.dataLayer) ? window.dataLayer.map(entry => Array.from(entry)) : [];
+ return {
+ commands: calls.map(call => call[0]),
+ initializationHasDate: calls[0]?.[1] instanceof Date,
+ initializedAt: calls[0]?.[1] instanceof Date ? calls[0][1].getTime() : null,
+ config: calls[1],
+ gtagType: typeof window.gtag,
+ };
+ });
+ expect(queue.commands, 'Queue initializes before configuring, with no duplicate tag setup').toEqual(['js', 'config']);
+ expect(queue.initializationHasDate).toBe(true);
+ expect(Number.isFinite(queue.initializedAt)).toBe(true);
+ expect(queue.initializedAt).toBeGreaterThan(0);
+ expect(queue.config).toEqual(['config', analyticsId]);
+ expect(queue.gtagType).toBe('function');
+}
+
export const test = base.extend({
- browserHealth: [async ({ page }, use) => {
+ browserHealth: [async ({ page, context }, use) => {
+ // Exercise the real inline initialization without loading Google code or sending visits.
+ // Only this exact, authorized URL is intercepted; browser failures stay strict.
+ await context.route(analyticsScriptUrl, route => route.fulfill({
+ status: 200,
+ contentType: 'application/javascript',
+ body: '/* Google tag intentionally stubbed in browser tests. */',
+ }));
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
await use();
expect(errors, 'Every interaction must finish without browser errors').toEqual([]);
if (await page.locator('meta[name="viewport"]').count()) {
+ await expectAnalytics(page);
await expect(page.locator('main')).toHaveCount(1);
await expect(page.locator('h1')).toHaveCount(1);
await expect(page.locator('html')).toHaveAttribute('lang', /^(en|zh)$/);