mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
162 lines
6.5 KiB
TypeScript
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()
|
|
})
|
|
})
|