Files

162 lines
6.5 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { pages, read, serveExport } from './export'
import { PLANS, block, serve } from './api'
/**
* THE STRUCTURED DATA IS SCHEMA.ORG'S, AND A PRICE IN IT IS THE CATALOG'S.
*
* JSON-LD is read by machines and never seen by a person, so a block that does
* not parse, a type schema.org does not define, a property on a type that does
* not take it, or a price the catalog does not charge is invisible on the page
* and wrong everywhere it is quoted. This reads every block of every exported
* page, and drives /pricing with the plans it was served.
*/
/**
* Every type the site's JSON-LD may name, each checked against schema.org's
* own list (schema.org/version/latest/schemaorg-current-https-types.csv,
* 2026-10-26). A new type joins here after the same check.
*/
const TYPES = new Set([
'Answer',
'Brand',
'BreadcrumbList',
'ContactPoint',
'Dataset',
'FAQPage',
'HowTo',
'HowToStep',
'ImageObject',
'ItemList',
'ListItem',
'NewsArticle',
'Offer',
'OfferCatalog',
'Organization',
'Person',
'PropertyValue',
'QuantitativeValue',
'Question',
'ScholarlyArticle',
'SoftwareApplication',
'SoftwareSourceCode',
'UnitPriceSpecification',
'WebAPI',
'WebPage',
'WebSite',
])
/** Every JSON-LD block in a document, as written. */
const blocks = (html: string) => [...html.matchAll(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1])
/** Every object in a parsed block, nested ones included. */
function objects(v: unknown): Record<string, unknown>[] {
if (Array.isArray(v)) return v.flatMap(objects)
if (!v || typeof v !== 'object') return []
const o = v as Record<string, unknown>
return [o, ...Object.values(o).flatMap(objects)]
}
const types = (o: Record<string, unknown>) => [o['@type']].flat().filter((t): t is string => typeof t === 'string')
test('every JSON-LD block in the export parses and names only schema.org types', () => {
const wrong: string[] = []
let seen = 0
for (const { route, file } of pages()) {
for (const raw of blocks(readFileSync(file, 'utf8'))) {
seen++
let doc: unknown
try {
doc = JSON.parse(raw)
} catch (e) {
wrong.push(`${route}: does not parse (${(e as Error).message})`)
continue
}
if ((doc as Record<string, unknown>)['@context'] !== 'https://schema.org') wrong.push(`${route}: @context is not https://schema.org`)
for (const o of objects(doc)) {
for (const t of types(o)) if (!TYPES.has(t)) wrong.push(`${route}: @type ${t}`)
// Two properties schema.org gives a SoftwareApplication no place for:
// the repository is SoftwareSourceCode's, a PropertyValue a Product's.
if (types(o).includes('SoftwareApplication')) {
for (const k of ['codeRepository', 'additionalProperty']) if (k in o) wrong.push(`${route}: SoftwareApplication.${k}`)
}
}
}
}
expect(seen, 'the site states itself in JSON-LD').toBeGreaterThan(1000)
expect(wrong.slice(0, 20), `${wrong.length} wrong`).toEqual([])
})
test('a model page offers a $0 price only for a free variant', () => {
// The build has no rate (the page reads GET /v1/pricing when it opens), so a
// paid model's structured data states none rather than a $0 the bill denies.
const wrong: string[] = []
for (const { route, file } of pages()) {
if (!route.startsWith('/models/')) continue
for (const raw of blocks(readFileSync(file, 'utf8'))) {
// The model's own node; the platform's (root layout) offers the Free plan on every page.
for (const o of objects(JSON.parse(raw))) {
if (!types(o).includes('SoftwareApplication') || o['@id'] === 'https://hanzo.ai/#platform') continue
const offer = o.offers as Record<string, unknown> | undefined
if (offer && String(offer.price) === '0' && !/[:-]free$/.test(route)) wrong.push(route)
}
}
}
expect(wrong.slice(0, 20), `${wrong.length} paid models offered at $0`).toEqual([])
})
/** The OfferCatalog /pricing states once the plans are read, or null while there is none. */
async function catalog(page: Page): Promise<Record<string, unknown> | null> {
const found = await page.locator('script[type="application/ld+json"]').allTextContents()
return (found.map((t) => JSON.parse(t) as Record<string, unknown>).find((d) => d['@type'] === 'OfferCatalog') ?? null)
}
test.describe('/pricing', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const site = await serveExport()
base = site.url
stop = site.close
})
test.afterAll(async () => stop())
test('the export carries no plan price in JSON-LD', () => {
for (const raw of blocks(read('pricing.html'))) expect(raw).not.toContain('OfferCatalog')
})
test('states the plans it was served as an OfferCatalog, each at the price the authority charges', async ({ page }) => {
await serve(page)
await page.goto(`${base}/pricing`)
await expect.poll(() => catalog(page), { timeout: 20_000 }).not.toBeNull()
const doc = (await catalog(page))!
expect(doc['@context']).toBe('https://schema.org')
const offers = doc.itemListElement as Record<string, unknown>[]
// The families the cards draw; DNS is another catalog's.
const sold = PLANS.filter((r) => ['personal', 'team', 'enterprise'].includes(r.category))
expect(offers.map((o) => o.name)).toEqual(sold.map((r) => r.name))
for (const r of sold) {
const o = offers.find((x) => x.name === r.name)!
if (r.contactSales) {
expect(o.price, `${r.name} is priced by conversation`).toBeUndefined()
continue
}
expect(o.price, r.name).toBe(r.price / 100)
expect(o.priceCurrency).toBe('USD')
const specs = o.priceSpecification as Record<string, unknown>[]
expect(specs.find((s) => s.billingDuration === 'P1M')?.price, `${r.name} by the month`).toBe(r.price / 100)
const year = specs.find((s) => s.billingDuration === 'P1Y')
if (r.annualTotal) expect(year?.price, `${r.name} by the year`).toBe(r.annualTotal / 100)
else if (!r.priceAnnual) expect(year, `${r.name} is not sold by the year`).toBeUndefined()
}
})
test('states no plan at all when api.hanzo.ai does not answer', async ({ page }) => {
await block(page)
await page.goto(`${base}/pricing`)
await expect(page.locator('[data-api="unreachable"]').first()).toBeVisible({ timeout: 20_000 })
expect(await catalog(page)).toBeNull()
})
})