Files
aia-website/www/tools/counters-browser.mjs

133 lines
6.4 KiB
JavaScript

/**
* Browser regression for the built live-counter artifact. Every API request is
* intercepted and fulfilled locally; this script never writes production data.
*/
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const browserName = process.argv.find((argument) => argument.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
: 'playwright';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
await new Promise((resolve) => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const apiOrigin = 'https://api.azinstitute4autism.com';
const calls = [];
let views = 100;
let likes = 5;
let browser;
let preview;
try {
const drivers = await import(moduleName);
const driver = drivers[browserName];
const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath ? { headless: true, executablePath } : { headless: true });
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
cwd: root,
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
stdio: 'ignore',
});
const reachable = () => new Promise((resolve) => {
const request = http.get(`${origin}/`, (response) => { response.resume(); resolve(response.statusCode === 200); });
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
for (let attempt = 0; attempt < 60 && !(await reachable()); attempt += 1) await delay(250);
assert.equal(await reachable(), true, 'Loopback Astro preview did not start');
const context = await browser.newContext();
await context.route(`${apiOrigin}/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, DELETE, OPTIONS',
'access-control-allow-headers': 'content-type',
} });
return;
}
const body = request.postDataJSON?.() ?? undefined;
calls.push({ method: request.method(), path: url.pathname, body });
let response;
if (url.pathname === '/stats/batch') {
response = Object.fromEntries(body.slugs.map((slug) => [slug, { likes, views }]));
} else if (url.pathname === '/views') {
views += 1;
response = { views };
} else if (url.pathname === '/likes' && request.method() === 'POST') {
likes += 1;
response = { count: likes };
} else if (url.pathname.startsWith('/likes/') && request.method() === 'DELETE') {
likes = Math.max(0, likes - 1);
response = { count: likes };
} else {
await route.fulfill({ status: 404, body: '{}' });
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': origin },
body: JSON.stringify(response),
});
});
const page = await context.newPage();
const article = '/library/aba-school-readiness-guide';
await page.goto(`${origin}${article}`);
await page.locator('[data-counter][data-track-view="true"] [data-views]').waitFor();
await assert.doesNotReject(async () => page.waitForFunction(() =>
document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '101'));
assert.equal(calls.filter((call) => call.path === '/views').length, 1);
assert.deepEqual(calls.find((call) => call.path === '/views').body, { slug: 'aba-school-readiness-guide' });
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'true');
assert.equal(calls.filter((call) => call.path === '/likes' && call.method === 'POST').length, 1);
assert.equal(await page.evaluate(() => localStorage.length), 1);
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), ['aia-liked-articles-v1']);
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'false');
assert.equal(calls.filter((call) => call.method === 'DELETE').length, 1);
await page.reload();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '102');
assert.equal(calls.filter((call) => call.path === '/views').length, 2, 'a real reload is a new view');
const beforeListing = calls.filter((call) => call.path === '/views').length;
await page.goto(`${origin}/library`);
await page.waitForFunction(() => document.querySelector('[data-counter] [data-views]')?.textContent !== '—');
assert.equal(calls.filter((call) => call.path === '/views').length, beforeListing, 'listing cards never report views');
for (const route of [
'/es/library/executive-functioning-skills-autism',
'/ar/library/executive-functioning-skills-autism',
]) {
await page.goto(`${origin}${route}`);
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"]')?.dataset.viewReported === 'true');
}
const translated = calls.filter((call) => call.path === '/views').slice(-2);
assert.deepEqual(translated.map((call) => call.body.slug), [
'executive-functioning-skills-autism',
'executive-functioning-skills-autism',
]);
console.log(JSON.stringify({ browser: browserName, version: browser.version(), checks: 18, productionRequests: 0 }));
await context.close();
} finally {
if (preview) preview.kill('SIGTERM');
if (browser) await browser.close();
}