Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Website/scripts/check-site.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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')) {
Expand Down
9 changes: 9 additions & 0 deletions Website/src/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
<!doctype html>
<html lang="{{ lang or 'en' }}">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', 'G-PGDS00DBRX');
</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ title }} · {{ page.url | section | upper }} · RestClient.Net</title>
<meta name="description" content="{{ description or excerpt or site.description }}">
Expand Down
57 changes: 57 additions & 0 deletions Website/tests-node/site-guards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<h1>Docs</h1>', `
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-PGDS00DBRX');
</script>`));
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://[email protected]/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('<h1>Docs</h1>', `<script async src="${src}"></script>`));
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('<h1 style="color:red">Docs</h1>', `
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX"></script>
<script src="https://cdn.test/unapproved.js"></script>
<link rel="stylesheet" href="https://www.googletagmanager.com/gtag/js?id=G-PGDS00DBRX">`));
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 ['<svg><style>text{font-size:99px}</style></svg>', '<svg><text style="font-size:99px">Hello</text></svg>']) {
Expand Down
39 changes: 39 additions & 0 deletions Website/tests/analytics.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
36 changes: 35 additions & 1 deletion Website/tests/fixtures.js
Original file line number Diff line number Diff line change
@@ -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)$/);
Expand Down
Loading