Files
clawhub/e2e/prod-http-smoke.e2e.test.ts

234 lines
7.2 KiB
TypeScript

/* @vitest-environment node */
import { Agent, setGlobalDispatcher } from "undici";
import { describe, expect, it } from "vitest";
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RATE_LIMIT_RETRIES = 3;
const MAX_RATE_LIMIT_WAIT_MS = 15_000;
const TRANSIENT_RETRY_DELAY_MS = 1_000;
const OG_IMAGE_TIMEOUT_MS = 45_000;
try {
setGlobalDispatcher(
new Agent({
connect: { timeout: REQUEST_TIMEOUT_MS },
}),
);
} catch {
// ignore dispatcher setup failures
}
function getSiteBase() {
return (
process.env.CLAWHUB_E2E_SITE?.trim() || process.env.CLAWHUB_SITE?.trim() || "https://clawhub.ai"
);
}
function getCanonicalSiteBase() {
return process.env.CLAWHUB_E2E_CANONICAL_SITE?.trim() || getSiteBase();
}
function getSkillSlug() {
return process.env.CLAWHUB_E2E_SKILL_SLUG?.trim() || "gifgrep";
}
function getSkillOwner() {
return process.env.CLAWHUB_E2E_SKILL_OWNER?.trim() || "steipete";
}
function withDeploymentProtection(init?: RequestInit): RequestInit {
const headers = new Headers(init?.headers);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
if (bypassSecret) {
headers.set("x-vercel-protection-bypass", bypassSecret);
}
return { ...init, headers };
}
async function fetchWithTimeout(
input: RequestInfo | URL,
init?: RequestInit,
timeoutMs = REQUEST_TIMEOUT_MS,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
try {
return await fetch(input, {
...withDeploymentProtection(init),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function parsePositiveNumber(value: string | null) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getRetryDelayMs(response: Response) {
const retryAfterSeconds = parsePositiveNumber(response.headers.get("Retry-After"));
if (retryAfterSeconds !== null) {
return Math.min(retryAfterSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS);
}
const relativeResetSeconds = parsePositiveNumber(response.headers.get("RateLimit-Reset"));
if (relativeResetSeconds !== null) {
return Math.min(relativeResetSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS);
}
const absoluteResetSeconds = parsePositiveNumber(response.headers.get("X-RateLimit-Reset"));
if (absoluteResetSeconds !== null) {
return Math.min(Math.max(absoluteResetSeconds * 1000 - Date.now(), 0), MAX_RATE_LIMIT_WAIT_MS);
}
return 1000;
}
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
options: { maxAttempts?: number; timeoutMs?: number } = {},
) {
const maxAttempts = options.maxAttempts ?? MAX_RATE_LIMIT_RETRIES;
for (let attempt = 1; ; attempt += 1) {
try {
const response = await fetchWithTimeout(input, init, options.timeoutMs);
if (attempt >= maxAttempts) return response;
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(response)));
continue;
}
if (response.status >= 500) {
await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAY_MS * attempt));
continue;
}
return response;
} catch (error) {
if (attempt >= maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAY_MS * attempt));
}
}
}
async function fetchHtml(pathname: string) {
const response = await fetchWithRetry(new URL(pathname, getSiteBase()), {
headers: { Accept: "text/html" },
});
expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("text/html");
return response.text();
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function expectLinkWithRelAndHref(html: string, rel: string, href: string) {
const relPattern = new RegExp(`\\brel=["']${escapeRegExp(rel)}["']`, "i");
const hrefPattern = new RegExp(`\\bhref=["']${escapeRegExp(href)}["']`, "i");
const hasLink = [...html.matchAll(/<link\b[^>]*>/gi)].some(
([tag]) => relPattern.test(tag) && hrefPattern.test(tag),
);
expect(hasLink, `expected HTML to contain <link> with rel="${rel}" and href="${href}"`).toBe(
true,
);
}
type SkillDetailResponse = {
skill: { slug: string; displayName: string; summary: string | null };
latestVersion: { version: string | null } | null;
owner: { handle: string | null };
};
let skillDetailPromise: Promise<SkillDetailResponse> | null = null;
async function fetchSkillDetail() {
if (!skillDetailPromise) {
skillDetailPromise = (async () => {
const response = await fetchWithRetry(
new URL(`/api/v1/skills/${getSkillSlug()}`, getSiteBase()),
{
headers: { Accept: "application/json" },
},
);
expect(response.ok).toBe(true);
const expectedTestBackend = process.env.CLAWHUB_E2E_EXPECT_TEST_BACKEND?.trim();
if (expectedTestBackend) {
expect(response.headers.get("x-clawhub-test-backend")).toBe(expectedTestBackend);
}
return (await response.json()) as SkillDetailResponse;
})();
}
return skillDetailPromise;
}
describe("prod http smoke", () => {
it("serves the home page shell from prod", async () => {
const html = await fetchHtml("/");
expect(html).toContain("<title>ClawHub");
expect(html).toContain('href="/skills"');
expect(html).toMatch(/href="\/(?:skills\/publish|publish-skill)"/);
expect(html).not.toContain("Something went wrong!");
});
it("serves SSR skill html for a public skill page", async () => {
const detail = await fetchSkillDetail();
const owner = detail.owner.handle || getSkillOwner();
const html = await fetchHtml(`/${owner}/${detail.skill.slug}`);
expect(html).toContain(`<title>${detail.skill.displayName} — ClawHub</title>`);
expectLinkWithRelAndHref(
html,
"canonical",
`${getCanonicalSiteBase()}/${owner}/skills/${detail.skill.slug}`,
);
if (detail.skill.summary) {
expect(html).toContain(detail.skill.summary);
}
expect(html).not.toContain("Loading skill");
});
it("serves the skill og image for the latest published version", async () => {
const detail = await fetchSkillDetail();
const owner = detail.owner.handle || getSkillOwner();
const params = new URLSearchParams({
slug: detail.skill.slug,
owner,
});
if (detail.latestVersion?.version) {
params.set("version", detail.latestVersion.version);
}
const response = await fetchWithRetry(
new URL(`/og/skill.png?${params.toString()}`, getSiteBase()),
undefined,
{ timeoutMs: OG_IMAGE_TIMEOUT_MS },
);
expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("image/png");
if (detail.latestVersion?.version) {
expect(response.headers.get("cache-control")).toContain("immutable");
}
});
it("serves the published SKILL.md file", async () => {
const detail = await fetchSkillDetail();
const response = await fetchWithRetry(
new URL(`/api/v1/skills/${detail.skill.slug}/file?path=SKILL.md`, getSiteBase()),
{
headers: { Accept: "text/plain" },
},
);
expect(response.ok).toBe(true);
expect((await response.text()).trim().length).toBeGreaterThan(0);
});
});