Files
vercel__workflow/docs/scripts/check-docs-smoke.mjs

339 lines
9.6 KiB
JavaScript

import { spawn } from 'node:child_process';
import { getTrustedSourcesHeaders } from '../../scripts/trusted-sources-headers.mjs';
/**
* Docs smoke checks.
*
* This script is intentionally small and dependency-free so it can run in CI.
* It validates critical public endpoints (OG images and sitemap) and can be
* extended with more lightweight checks over time.
*
* When DEPLOYMENT_URL or OG_BASE_URL is set, it targets a remote deployment.
* Otherwise it starts the local docs server and tests against localhost.
*/
const PORT = process.env.OG_TEST_PORT || '3100';
const HOST = '127.0.0.1';
const rawBaseUrl = process.env.DEPLOYMENT_URL || process.env.OG_BASE_URL || '';
const BASE_URL = rawBaseUrl
? rawBaseUrl.startsWith('http')
? rawBaseUrl
: `https://${rawBaseUrl}`
: `http://${HOST}:${PORT}`;
const USE_REMOTE = Boolean(rawBaseUrl);
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const assertNoProtection = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
redirect: 'manual',
headers: await getTrustedSourcesHeaders(),
});
const location = res.headers.get('location') || '';
if (
res.status === 307 &&
(location.includes('vercel.com/login') ||
location.includes('/_vercel/login'))
) {
throw new Error(
`${path} redirected to Vercel login; check deployment protection/bypass`
);
}
};
const waitForServer = async (url, timeoutMs = 30_000) => {
const startedAt = Date.now();
let lastStatus = null;
let lastBody = null;
// x-vercel-id identifies the Vercel edge node that served the response.
// While the proxy-side trusted-sources changes are rolling out gradually,
// a failing request may be hitting an edge node that hasn't received the
// fix yet — surfacing the id in the timeout error makes that visible.
let lastVercelId = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await fetch(url, {
headers: await getTrustedSourcesHeaders(),
});
if (res.ok) return;
lastStatus = res.status;
lastVercelId = res.headers.get('x-vercel-id');
// Capture the first ~500 chars of the body to help diagnose
// protection/auth failures (e.g. SSO login redirects).
try {
lastBody = (await res.text()).slice(0, 500);
} catch {
lastBody = '<unable to read body>';
}
} catch (err) {
lastStatus = `network error: ${(err && err.message) || err}`;
}
await wait(500);
}
throw new Error(
`Timed out waiting for server at ${url} (last status: ${lastStatus}; x-vercel-id: ${lastVercelId}; body: ${lastBody})`
);
};
const assertPngResponse = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('image/png')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const buf = new Uint8Array(await res.arrayBuffer());
for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
if (buf[i] !== PNG_SIGNATURE[i]) {
throw new Error(`${path} did not start with PNG signature bytes`);
}
}
};
const assertHtmlMeta = async (path, expectedOgImagePath) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const html = await res.text();
const ogImage = html.match(
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogImage) {
throw new Error(`${path} missing og:image meta tag`);
}
if (expectedOgImagePath) {
const normalized = ogImage.startsWith('http')
? new URL(ogImage).pathname
: ogImage;
if (normalized !== expectedOgImagePath) {
throw new Error(
`${path} og:image was ${ogImage}, expected ${expectedOgImagePath}`
);
}
}
const twitterImage = html.match(
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!twitterImage) {
throw new Error(`${path} missing twitter:image meta tag`);
}
if (expectedOgImagePath) {
const normalized = twitterImage.startsWith('http')
? new URL(twitterImage).pathname
: twitterImage;
if (normalized !== expectedOgImagePath) {
throw new Error(
`${path} twitter:image was ${twitterImage}, expected ${expectedOgImagePath}`
);
}
}
const ogTitle = html.match(
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogTitle) {
throw new Error(`${path} missing og:title meta tag`);
}
const ogDescription = html.match(
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogDescription) {
throw new Error(`${path} missing og:description meta tag`);
}
};
const checks = [
{
name: 'Deployment protection',
run: () => assertNoProtection('/og'),
},
{
name: 'OG default image',
run: () => assertPngResponse('/og'),
},
{
name: 'HTML meta - docs root',
run: () => assertHtmlMeta('/docs', '/og/getting-started/image.png'),
},
{
name: 'HTML meta - docs idempotency',
run: () =>
assertHtmlMeta(
'/docs/foundations/idempotency',
'/og/foundations/idempotency/image.png'
),
},
{
name: 'HTML meta - cookbook sequential & parallel',
run: () =>
assertHtmlMeta(
'/cookbook/common-patterns/sequential-and-parallel',
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
),
},
{
name: 'HTML meta - docs get-writable',
run: () =>
assertHtmlMeta(
'/docs/api-reference/workflow/get-writable',
'/og/api-reference/workflow/get-writable/image.png'
),
},
{
name: 'HTML meta - worlds index',
run: () => assertHtmlMeta('/worlds', '/og/worlds'),
},
{
name: 'HTML meta - world local',
run: () => assertHtmlMeta('/worlds/local', '/og/worlds/local'),
},
{
name: 'HTML meta - world postgres',
run: () => assertHtmlMeta('/worlds/postgres', '/og/worlds/postgres'),
},
{
name: 'HTML meta - world vercel',
run: () => assertHtmlMeta('/worlds/vercel', '/og/worlds/vercel'),
},
{
name: 'HTML meta - worlds building-a-world',
run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'),
},
{
name: 'HTML meta - worlds upgrading-to-v5 (v5)',
run: () => assertHtmlMeta('/v5/worlds/upgrading-to-v5', '/og/worlds'),
},
{
name: 'HTML meta - world vercel (v5)',
run: () => assertHtmlMeta('/v5/worlds/vercel', '/og/worlds/vercel'),
},
{
name: 'OG docs page image',
run: () => assertPngResponse('/og/foundations/idempotency/image.png'),
},
{
name: 'OG docs root image',
run: () => assertPngResponse('/og/getting-started/image.png'),
},
{
name: 'OG cookbook common-patterns image',
run: () =>
assertPngResponse(
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
),
},
{
name: 'OG docs reference image',
run: () =>
assertPngResponse('/og/api-reference/workflow/get-writable/image.png'),
},
{
name: 'OG worlds index image',
run: () => assertPngResponse('/og/worlds'),
},
{
name: 'OG world image (local)',
run: () => assertPngResponse('/og/worlds/local'),
},
{
name: 'OG world image (postgres)',
run: () => assertPngResponse('/og/worlds/postgres'),
},
{
name: 'OG world image (vercel)',
run: () => assertPngResponse('/og/worlds/vercel'),
},
{
name: 'Sitemap',
run: () => assertXmlResponse('/sitemap.xml'),
},
];
const assertXmlResponse = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('xml') && !contentType.includes('text/plain')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const text = await res.text();
if (!text.includes('<?xml')) {
throw new Error(`${path} did not contain xml declaration`);
}
};
const run = async () => {
let child = null;
let stopServer = async () => {};
let cleanup = async () => {};
if (!USE_REMOTE) {
child = spawn('pnpm', ['-C', 'docs', 'start'], {
env: {
...process.env,
PORT,
HOSTNAME: HOST,
},
stdio: 'inherit',
});
let shuttingDown = false;
stopServer = async () => {
if (shuttingDown) return;
shuttingDown = true;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
wait(5_000),
]);
if (!child.killed) {
child.kill('SIGKILL');
}
};
cleanup = async () => {
try {
await stopServer();
} catch {
// ignore cleanup errors
}
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('exit', cleanup);
}
try {
await waitForServer(`${BASE_URL}/og`);
for (const check of checks) {
console.log(`Running docs smoke check: ${check.name}`);
await check.run();
}
await stopServer();
} catch (error) {
await stopServer();
throw error;
}
};
run().catch((error) => {
console.error(error);
process.exit(1);
});