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 ['', 'Hello']) { 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)$/);