mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
WHICH products exist had two answers. Commerce's catalog said 84 in ten
categories; lib/data/cloud-primitives.ts said 60 in ten of its own, and the
two disagreed about the categories themselves — the site sold "Payments",
which commerce does not carry, and had no column for "Dev", which it does.
e2e/catalog-agreement.spec.ts was red in both directions and had been.
The catalog is now the source. scripts/sync-catalog.mjs fetches it at build
time (prebuild, beside sync-pricing) and writes lib/data/catalog.json; the
taxonomy reads that file and owns only the prose.
Reachability is the inclusion gate, and it is what makes hydration safe.
31 of the 84 name an apiPath /v1/openapi.json does not serve — every one
marked "enabled", every one a live 404 — so rendering the catalog raw would
have been worse than the hand list. A product ships iff the served document
carries its apiPath; a kind:"client" row is judged by having a page here
instead, because it consumes the API and no path can ever be right for it.
53 render today, and 72 will the day commerce lands the 13 corrected paths
and the kind field, with no change to this repo.
The destination is resolved against app/ by the same sync: a bespoke page
where one exists (28), a generated /cloud/<slug> overview otherwise (25),
built from cloudPrimitiveSlugs. So a leaf cannot be a dead link either way.
The overview grew a facts row — the operation that answers and the Google
Cloud service it stands in for — because a product can now reach that page
before anyone writes copy for it, and a name with two CTAs is the empty stub
that component exists to avoid.
scripts/catalog.mjs holds the probe once, for the sync and the audit both:
two implementations of "does this path answer" is how a gate comes to say a
product is missing while the menu still advertises it. The audit keeps its
ratchet and loses one entry — cloud serves /v1/tokens/{chain}/{address} now,
so `tokens` had to give its exemption back, which is the point of a ratchet.
Never fails the build and never regresses, both measured against a stub
rather than reasoned about: an unreachable API, a payload that is not a
catalog, a document with no paths, and a catalog rendering fewer products
than the snapshot each keep what is committed and say so. A shrink is
refused because a degraded response is likelier than a retired product line,
and it prints what it would have dropped.
capabilityCount and categoryCount are deleted — no call sites remained — and
no count is left in copy: the menu hands off with "All Observe →" and the
category cards dropped their leaf-count badge. Membership is measured per
build, so a number on the page is a fact about the API's morning.
Two suites stop mirroring the taxonomy by hand. products.spec.ts reads the
leaves (its copy had rotted to four pages that no longer exist and a
/blockchain section that was dropped), and catalog-agreement.spec.ts asks
the question a build-time snapshot actually raises — is it still true? — by
re-measuring the gate against the live document rather than trusting it.
mega-menu's per-category link count became a set: a category is linked three
times on the homepage now and the third is below the fold, so the count was
measuring whether whileInView had fired.
28 destinations left the products nav and 19 arrived. Four were the orphans
the agreement test already allowlisted; the rest leave because commerce does
not carry them (/payments, /commerce, /ledger, /team, /sign, /captable, the
eight web3.hanzo.ai leaves, …). Every one of those pages still builds,
answers and sits in sitemap.xml — only the menu stopped advertising them,
and the repair is a row in the catalog, not a line here.
125 lines
5.8 KiB
TypeScript
125 lines
5.8 KiB
TypeScript
// The site's product taxonomy must agree with commerce's catalog.
|
|
//
|
|
// It used to be TWO hand-kept lists, and this file asked whether they matched.
|
|
// They did not: the site sold a "Payments" category commerce does not carry and
|
|
// had no column for the "Dev" one it does, so the categories test was red in
|
|
// both directions at once.
|
|
//
|
|
// The taxonomy is now DERIVED from the catalog — `scripts/sync-catalog.mjs`
|
|
// fetches it at build time and writes `lib/data/catalog.json`, which
|
|
// `lib/data/cloud-primitives.ts` reads — so "do the two lists match" is no
|
|
// longer a question anyone can answer wrongly. The question that replaced it is
|
|
// the one a build-time snapshot actually raises: IS THE SNAPSHOT STILL TRUE?
|
|
// A committed file goes stale silently, and the whole point of the snapshot is
|
|
// that the site can deploy without the API, which is exactly the condition
|
|
// under which nobody would notice.
|
|
//
|
|
// So this reads the LIVE catalog and the LIVE served document, and holds the
|
|
// committed snapshot to both:
|
|
//
|
|
// 1. the categories the site renders are the categories commerce carries
|
|
// 2. every product the site renders is still in the catalog
|
|
// 3. every product the site renders still ANSWERS — the inclusion gate is
|
|
// re-measured here rather than trusted from build time
|
|
// 4. the catalog files nothing under a category the site has no column for
|
|
//
|
|
// (3) is the load-bearing one. The snapshot's promise is that nothing on this
|
|
// site advertises a capability that 404s; a promise kept only at build time is
|
|
// kept only until the API changes.
|
|
//
|
|
// pnpm exec playwright test e2e/catalog-agreement.spec.ts
|
|
// CATALOG_API=https://api.hanzo.ai pnpm exec playwright test e2e/catalog-agreement.spec.ts
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
import { cloudCategories, categorySlugs } from "../lib/data/cloud-primitives";
|
|
|
|
const CATALOG_API = process.env.CATALOG_API ?? "https://api.hanzo.ai";
|
|
const CATALOG_URL = `${CATALOG_API}/v1/commerce/catalog?brand=hanzo`;
|
|
const DOCUMENT_URL = `${CATALOG_API}/v1/openapi.json`;
|
|
|
|
interface Catalog {
|
|
brand: string;
|
|
categories: { id: string; label: string; order: number }[];
|
|
products: { id: string; slug: string; name: string; category: string; apiPath?: string; kind?: string }[];
|
|
}
|
|
|
|
async function json<T>(url: string): Promise<T> {
|
|
const res = await fetch(url);
|
|
// Deliberately NOT skipped on failure. These endpoints are public and
|
|
// unauthenticated — the build itself reads them — so an unreachable API is a
|
|
// finding, not a reason to pass quietly.
|
|
expect(res.ok, `${url} answered ${res.status}`).toBe(true);
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
/** Every product the site renders, flattened out of the derived taxonomy. */
|
|
const rendered = cloudCategories.flatMap((c) => c.items);
|
|
|
|
test.describe("site taxonomy vs commerce catalog", () => {
|
|
test("categories match exactly", async () => {
|
|
const catalog = await json<Catalog>(CATALOG_URL);
|
|
const live = [...new Set(catalog.categories.map((c) => c.id))].sort();
|
|
const site = [...new Set(categorySlugs)].sort();
|
|
|
|
// Reported as two directions, because they mean different things: a category
|
|
// commerce added is a family the site has no route for; one only the site has
|
|
// is a menu column selling nothing. Both now mean the same CAUSE — a stale
|
|
// lib/data/catalog.json — and the fix is `pnpm sync:catalog`.
|
|
expect(
|
|
live.filter((c) => !site.includes(c)),
|
|
"commerce has categories the site does not route — the snapshot is stale",
|
|
).toEqual([]);
|
|
expect(
|
|
site.filter((c) => !live.includes(c)),
|
|
"the site routes categories commerce does not carry — the snapshot is stale",
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("every product the site renders is still in the catalog", async () => {
|
|
const catalog = await json<Catalog>(CATALOG_URL);
|
|
const live = new Set(catalog.products.map((p) => p.slug));
|
|
|
|
expect(
|
|
rendered.map((i) => i.slug).filter((s) => !live.has(s)),
|
|
"the site renders products commerce no longer carries — re-run `pnpm sync:catalog`",
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("every product the site renders still answers", async () => {
|
|
const document = await json<{ paths: Record<string, unknown> }>(DOCUMENT_URL);
|
|
const paths = Object.keys(document.paths ?? {});
|
|
expect(paths.length, "the served document carries no paths").toBeGreaterThan(0);
|
|
|
|
// The same predicate scripts/catalog.mjs applies at build time: the exact
|
|
// path, or any path beneath it. Stated once there and once here because a
|
|
// test that imports the thing it is checking proves only that the thing
|
|
// agrees with itself.
|
|
const serves = (api: string) =>
|
|
paths.includes(api) || paths.some((p) => p.startsWith(api.replace(/\/$/, "") + "/"));
|
|
|
|
// A leaf with no `api` is a CLIENT — a CLI, an SDK, the API reference.
|
|
// Those consume the API and are judged by having a page, which
|
|
// e2e/products.spec.ts measures; there is no operation to probe.
|
|
const dead = rendered.filter((i) => i.api && !serves(i.api));
|
|
expect(
|
|
dead.map((i) => `${i.slug} -> ${i.api}`),
|
|
"the site advertises capabilities api.hanzo.ai no longer serves",
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("the catalog is grouped by categories the site renders", async () => {
|
|
const catalog = await json<Catalog>(CATALOG_URL);
|
|
// products[].category carries the LABEL ("AI"), categories[].id the slug
|
|
// ("ai"). Comparing one to the other reports every category as missing — a
|
|
// false alarm about field NAMES rather than a real disagreement about values.
|
|
const labels = new Set(catalog.categories.map((c) => c.label));
|
|
const stray = [...new Set(catalog.products.map((p) => p.category))].filter(
|
|
(c) => !labels.has(c),
|
|
);
|
|
expect(
|
|
stray,
|
|
"commerce files products under categories the site has no column for",
|
|
).toEqual([]);
|
|
});
|
|
});
|