mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
445 lines
27 KiB
TypeScript
445 lines
27 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { signIn } from '../session'
|
||
import rules from '../../lib/data/rewards-rules.json' with { type: 'json' }
|
||
|
||
/**
|
||
* HANZO REWARDS SAYS WHAT THE CLOUD AWARDS, AND DRAWS ONLY WHAT IT ANSWERS.
|
||
*
|
||
* What earns is the rules table (the build's snapshot of GET /v1/rewards/rules,
|
||
* then the endpoint's own answer, so a value changed there shows on the page);
|
||
* the board is GET /v1/rewards on the rules' tabs, all time first; the standing,
|
||
* badges, posts and redemptions are /me's, /posts' and /redemptions'; a redemption
|
||
* is whole dollars and a refusal says the cloud's reason; a post says its state;
|
||
* every share control carries the rules' disclosure; and a track only some are
|
||
* invited to never shows to anyone else.
|
||
*/
|
||
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
type Table = typeof rules
|
||
|
||
/** Nothing off this machine answers but what a test routes. */
|
||
const local = (page: Page) => page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
|
||
|
||
const reply = (route: Route, json: unknown, status = 200) => {
|
||
const req = route.request()
|
||
const headers = {
|
||
'access-control-allow-origin': req.headers().origin ?? base,
|
||
'access-control-allow-credentials': 'true',
|
||
'access-control-allow-headers': 'content-type, authorization, x-org-id',
|
||
'access-control-allow-methods': 'GET, POST, PUT',
|
||
}
|
||
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
|
||
return route.fulfill({ status, headers, json })
|
||
}
|
||
|
||
/** The rules with one change: a value the page must print from the cloud, not from its snapshot. */
|
||
const changed = (edit: (r: Table) => void): Table => {
|
||
const r = structuredClone(rules)
|
||
edit(r)
|
||
return r
|
||
}
|
||
const points = (r: Table, id: string, n: number) => {
|
||
const k = r.kinds.find((x) => x.id === id)
|
||
if (!k) throw new Error(`no kind ${id} in the rules`)
|
||
k.points = n
|
||
}
|
||
|
||
const BOARD = {
|
||
rows: [
|
||
{ rank: 1, name: 'zeekay', you: false, points: 900, level: 'Shipper', since: '2026-09-02' },
|
||
{ rank: 2, name: null, you: false, points: 410, level: 'Builder', since: '2026-09-04' },
|
||
],
|
||
you: null,
|
||
of: 2,
|
||
source: '',
|
||
board: 'overall',
|
||
period: 'all',
|
||
settled: { usage: true, referrals: true },
|
||
}
|
||
|
||
const EARNED = new Set(['first-spark', 'wordsmith', 'model-hopper', 'beta-pioneer', 'first-friend', 'true-advocate'])
|
||
const POSTS = [
|
||
{ id: 'p1', network: 'x', url: 'https://x.com/ada/status/1', status: 'pending', points: 10, at: '2026-10-24T00:00:00Z', countsAt: '2026-10-31T00:00:00Z', daysLeft: 5, note: '' },
|
||
{ id: 'p2', network: 'bluesky', url: 'https://bsky.app/profile/ada/post/2', status: 'confirmed', points: 10, at: '2026-10-10T00:00:00Z', countsAt: '2026-10-17T00:00:00Z', daysLeft: 0, note: '' },
|
||
{ id: 'p3', network: 'reddit', url: 'https://www.reddit.com/r/x/comments/3', status: 'removed', points: 0, at: '2026-10-09T00:00:00Z', countsAt: '', daysLeft: 0, note: 'The post no longer carries your link.' },
|
||
{ id: 'p4', network: 'linkedin', url: 'https://www.linkedin.com/posts/ada-4', status: 'review', points: 10, at: '2026-10-25T00:00:00Z', countsAt: '', daysLeft: 0, note: '' },
|
||
]
|
||
|
||
/** A member's standing, as GET /v1/rewards/me answers it. */
|
||
const ME = {
|
||
rank: 7,
|
||
of: 240,
|
||
points: 141,
|
||
since: '2026-10-01',
|
||
level: { name: 'Builder', points: 100 },
|
||
next: { name: 'Shipper', points: 500 },
|
||
toNext: 12,
|
||
sources: { explore: 30, referrals: 50, beta: 5, legacy: 56 },
|
||
badges: rules.badges.map((b) => ({ id: b.id, name: b.name, earned: EARNED.has(b.id), at: EARNED.has(b.id) ? '2026-10-02T00:00:00Z' : '' })),
|
||
redeemable: 500,
|
||
allowance: { monthCents: 2000, usedCents: 0, leftCents: 2000, open: true },
|
||
legacy: 56,
|
||
private: 0,
|
||
staff: false,
|
||
usage: { tokens: 3_600_000, next: { tokens: 5_000_000, points: 30, badge: 'tokens-5m' } },
|
||
github: { login: 'ada', linked: true },
|
||
history: [
|
||
{ kind: 'retained', source: 'referrals', points: 25, ref: 'org-b', note: '', at: '2026-10-20T00:00:00Z' },
|
||
{ kind: 'retained', source: 'referrals', points: 25, ref: 'org-c', note: '', at: '2026-10-21T00:00:00Z' },
|
||
{ kind: 'active', source: 'referrals', points: 25, ref: 'org-b', note: '', at: '2026-10-12T00:00:00Z' },
|
||
{ kind: 'models-3', source: 'explore', points: 10, ref: '', note: '', at: '2026-10-05T00:00:00Z' },
|
||
{ kind: 'text', source: 'explore', points: 10, ref: '', note: '', at: '2026-10-03T00:00:00Z' },
|
||
{ kind: 'first-task', source: 'explore', points: 10, ref: '', note: '', at: '2026-10-03T00:00:00Z' },
|
||
{ kind: 'applied', source: 'beta', points: 5, ref: '', note: '', at: '2026-10-01T00:00:00Z' },
|
||
],
|
||
posts: POSTS,
|
||
pending: 20,
|
||
}
|
||
|
||
/** The rewards API as the cloud answers it; every board request and every write is kept. */
|
||
const rewards = async (page: Page, o: { table?: Table; board?: unknown; me?: unknown; track?: unknown } = {}) => {
|
||
const asked: URL[] = []
|
||
const wrote: { path: string; body: unknown }[] = []
|
||
await page.route((u) => u.pathname === '/v1/rewards/rules', (r) => reply(r, o.table ?? rules))
|
||
await page.route(
|
||
(u) => u.pathname === '/v1/rewards',
|
||
(r) => {
|
||
asked.push(new URL(r.request().url()))
|
||
return reply(r, o.board ?? BOARD)
|
||
},
|
||
)
|
||
await page.route((u) => u.pathname === '/v1/rewards/me', (r) => reply(r, o.me ?? ME))
|
||
await page.route((u) => u.pathname === '/v1/rewards/optin', (r) => reply(r, { user: { listed: false, handle: '', canSet: true } }))
|
||
await page.route((u) => u.pathname === '/v1/rewards/posts', (r) => {
|
||
if (r.request().method() === 'POST') {
|
||
const body = JSON.parse(r.request().postData() || '{}')
|
||
wrote.push({ path: '/v1/rewards/posts', body })
|
||
return reply(r, { ...POSTS[0], id: 'p5', url: body.url, daysLeft: 7 })
|
||
}
|
||
return reply(r, { posts: POSTS, pending: 20 })
|
||
})
|
||
await page.route((u) => u.pathname === '/v1/rewards/redemptions', (r) => reply(r, { redemptions: [] }))
|
||
// Each badge's art, as the cloud draws it: an SVG.
|
||
await page.route((u) => /^\/v1\/rewards\/badges\/[^/]+\/art$/.test(u.pathname), (r) =>
|
||
r.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44"><rect width="44" height="44"/></svg>' }),
|
||
)
|
||
// The invite-only track answers what an address nobody serves answers, unless the test lets the reader in.
|
||
await page.route((u) => u.pathname === '/v1/rewards/cyber', (r) => (o.track ? reply(r, o.track) : reply(r, { status: 404 }, 404)))
|
||
await page.route((u) => u.pathname === '/v1/referral', (r) => reply(r, { code: 'ada7', link: 'https://hanzo.ai/beta?ref=ada7', counts: { total: 2, signup: 1, qualified: 1 }, referrals: [] }))
|
||
await page.route((u) => u.pathname === '/v1/provider/github/user', (r) => reply(r, { configured: true, connected: true, login: 'ada' }))
|
||
await page.route((u) => u.pathname === '/v1/event', (r) => reply(r, {}))
|
||
return { asked, wrote }
|
||
}
|
||
|
||
const nav = (page: Page, name: string) => page.locator('[data-slot="rewards-nav"]').getByRole('button', { name, exact: true }).click()
|
||
|
||
/** Nothing on the page reaches past the viewport's right edge, but inside a row that scrolls or clips on purpose. */
|
||
const fits = async (page: Page) =>
|
||
expect(
|
||
await page.evaluate(() =>
|
||
[...document.querySelectorAll('#main *')]
|
||
.filter((el) => {
|
||
for (let up = el.parentElement; up && up.id !== 'main'; up = up.parentElement) if (getComputedStyle(up).overflowX !== 'visible') return false
|
||
return true
|
||
})
|
||
.filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1)
|
||
.map((el) => `${el.tagName.toLowerCase()}${el.getAttribute('data-slot') ? `[${el.getAttribute('data-slot')}]` : ''}: ${(el.textContent ?? '').trim().slice(0, 40)}`)
|
||
.slice(0, 5),
|
||
),
|
||
).toEqual([])
|
||
|
||
test('signed out, /leaderboard opens on the board: the rules’ tabs, all time first, then this month', async ({ page }) => {
|
||
await local(page)
|
||
const { asked } = await rewards(page)
|
||
await page.goto(`${base}/leaderboard`)
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Earn points for building, sharing and shipping')
|
||
const board = page.locator('[data-slot="rewards-board"]')
|
||
await expect(board.getByText('@zeekay')).toBeVisible({ timeout: 20_000 })
|
||
await expect(board.getByText('A builder')).toBeVisible()
|
||
await expect(page.locator('[data-slot="leaderboard-join"]').getByRole('link', { name: /Create account/ }).first()).toHaveAttribute('href', '/signup?next=%2Fleaderboard')
|
||
await expect(page.locator('[data-slot="rewards-summary"]')).toHaveCount(0)
|
||
for (const name of ['Overview', 'Earn', 'Collection', 'Leaderboard', 'Redeem']) await expect(page.locator('[data-slot="rewards-nav"]').getByRole('button', { name, exact: true })).toBeVisible()
|
||
await expect(page.locator('[data-slot="rewards-nav"]').getByRole('link', { name: 'Bounties' })).toHaveAttribute('href', '/bounty')
|
||
|
||
// The tabs are the rules', and the first read is all time.
|
||
await expect(board.locator('[data-slot="rewards-tabs"] button')).toHaveText(rules.tabs.map((x) => x.name))
|
||
expect(asked[0].searchParams.get('period')).toBe('all')
|
||
expect(asked[0].searchParams.get('board')).toBe('overall')
|
||
await board.getByRole('button', { name: 'Builders' }).click()
|
||
await board.getByRole('button', { name: 'This month' }).click()
|
||
await expect.poll(() => asked.some((u) => u.searchParams.get('board') === 'builders' && u.searchParams.get('period') === 'month')).toBe(true)
|
||
|
||
// Signed out, Overview and Redeem ask for the account.
|
||
await nav(page, 'Redeem')
|
||
await expect(page.locator('[data-slot="rewards-redeem-note"]')).toContainText('cannot be transferred')
|
||
await expect(page.locator('[data-slot="leaderboard-join"]').first()).toBeVisible()
|
||
})
|
||
|
||
test('Earn prints the rules table by source: Coming while the cloud does not award a kind, Redeemable where its points redeem, and what never earns', async ({ page }) => {
|
||
await local(page)
|
||
await rewards(page)
|
||
await page.goto(`${base}/leaderboard#earn`)
|
||
const how = page.locator('[data-slot="leaderboard-how"]')
|
||
await expect(how).toBeVisible({ timeout: 20_000 })
|
||
const listed = rules.kinds.filter((k) => !['legacy', 'reversal', 'redeem', 'unredeem'].includes(k.id))
|
||
await expect(how.locator('[data-kind]')).toHaveCount(listed.length)
|
||
for (const k of listed.filter((x) => x.id !== 'tier')) {
|
||
const tile = how.locator(`[data-kind="${k.id}"]`)
|
||
await expect(tile).toContainText(`+${k.points}`)
|
||
await expect(tile.locator('[data-slot="rewards-coming"]')).toHaveCount(k.live ? 0 : 1)
|
||
await expect(tile.locator('[data-slot="rewards-credit"]')).toHaveCount(k.credit ? 1 : 0)
|
||
}
|
||
await expect(how.locator('[data-slot="rewards-cap"]')).toHaveCount(rules.caps.length)
|
||
await expect(how.locator('[data-slot="rewards-milestones"] [data-done]')).toHaveCount(0)
|
||
await expect(how.locator('[data-slot="rewards-excluded"]')).toContainText('Sourcing investors or financing.')
|
||
await expect(how.locator('[data-slot="rewards-levels"] [data-level]')).toHaveText(rules.levels.map((l) => new RegExp(`^${l.name}`)))
|
||
const words = (await how.innerText()).toLowerCase()
|
||
for (const banned of ['payout', 'airdrop', 'earn money', 'cash out']) expect(words).not.toContain(banned)
|
||
expect(words).toContain('no cash value')
|
||
})
|
||
|
||
test('a value changed in the cloud’s rules is the value /leaderboard, /referral and /bounty print', async ({ page }) => {
|
||
await local(page)
|
||
const table = changed((r) => {
|
||
points(r, 'active', 99)
|
||
points(r, 'merged', 77)
|
||
})
|
||
await rewards(page, { table })
|
||
await page.goto(`${base}/leaderboard#earn`)
|
||
await expect(page.locator('[data-slot="leaderboard-how"] [data-kind="active"]')).toContainText('+99', { timeout: 20_000 })
|
||
await page.goto(`${base}/referral`)
|
||
await expect(page.locator('[data-slot="referral-step"]').last()).toContainText('99 points when a builder from your link runs a first AI task', { timeout: 20_000 })
|
||
await expect(page.locator('[data-slot="referral-step"]').last()).toContainText('Referrals: at most 100 points in any 30 days.')
|
||
await expect(page.locator('[data-slot="referral-scores"] [data-kind="active"]')).toContainText('+99')
|
||
await expect(page.locator('[data-slot="referral-scores"] [data-kind="share_verified"]')).toBeVisible()
|
||
await page.goto(`${base}/bounty`)
|
||
await expect(page.locator('[data-slot="rewards-rules"] [data-kind="merged"]')).toContainText('+77', { timeout: 20_000 })
|
||
})
|
||
|
||
test('signed in, Overview is the member’s level, points, pending, next achievements and suggestions from what they did; a returned GitHub grant is completed once', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page, { board: { ...BOARD, you: { rank: 7, name: 'ada', you: true, points: 141, level: 'Builder', since: '2026-10-01' }, of: 240 } })
|
||
const grants: string[] = []
|
||
await page.route((u) => u.pathname === '/v1/provider/github/user/complete', (r) => {
|
||
grants.push(JSON.parse(r.request().postData() || '{}').grant)
|
||
return reply(r, { configured: true, connected: true, login: 'ada' })
|
||
})
|
||
await page.goto(`${base}/leaderboard?complete=github&grant=g1`)
|
||
const summary = page.locator('[data-slot="rewards-summary"]')
|
||
await expect(summary.locator('[data-slot="rewards-points"]')).toHaveText('141 pts', { timeout: 20_000 })
|
||
await expect(summary.locator('[data-slot="rewards-level"]')).toHaveText('Builder')
|
||
await expect(summary.locator('[data-slot="rewards-pending"]')).toHaveText('+20 pending')
|
||
await expect(summary.locator('[data-slot="rewards-rank"]')).toHaveText('#7 / 240')
|
||
const overview = page.locator('[data-slot="rewards-overview"]')
|
||
await expect(overview.locator('[data-slot="rewards-next-level"]')).toContainText('359 points to Shipper')
|
||
await expect(overview.locator('[data-goal="tokens-5m"]')).toContainText('3.6M / 5M tokens')
|
||
await expect(overview.locator('[data-goal="model-voyager"]')).toContainText('at least 3 / 5 models')
|
||
await expect(overview.locator('[data-goal="community-connector"]')).toContainText('2 / 5')
|
||
// Text tried, so the next suggestions are the other kinds of model; never a token milestone.
|
||
const suggested = await overview.locator('[data-suggest]').evaluateAll((els) => els.map((e) => e.getAttribute('data-suggest')))
|
||
expect(suggested.length).toBeGreaterThan(0)
|
||
expect(suggested).not.toContain('text')
|
||
expect(suggested).not.toContain('tier')
|
||
expect(suggested[0]).toBe('image')
|
||
await expect(page.locator('[data-slot="rewards-github"]').first()).toContainText('@ada')
|
||
expect(grants).toEqual(['g1'])
|
||
expect(new URL(page.url()).search).toBe('')
|
||
// The rank to share carries the rules' disclosure, and the link carries the member's code.
|
||
const share = page.locator('[data-slot="rewards-share"]')
|
||
await expect(share.locator('[data-slot="rewards-post"]')).toContainText('I’m #7 on the Hanzo leaderboard with 141 points. I earn Hanzo Rewards points.')
|
||
await expect(share.locator('[data-slot="rewards-disclosure"]')).toHaveText(rules.disclosure)
|
||
const x = await share.getByRole('link', { name: 'Share on X' }).getAttribute('href')
|
||
expect(decodeURIComponent(x ?? '')).toContain('hanzo.ai/leaderboard?ref=ada7')
|
||
})
|
||
|
||
test('Share and earn takes a post’s address and lists each post’s state, with the pending total and the disclosure beside it', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
const { wrote } = await rewards(page)
|
||
await page.goto(`${base}/leaderboard`)
|
||
const card = page.locator('[data-slot="rewards-posts"]')
|
||
await expect(card.locator('[data-post]')).toHaveCount(4, { timeout: 20_000 })
|
||
await expect(card.locator('[data-post="pending"]')).toContainText('Pending · counts in 5 days')
|
||
await expect(card.locator('[data-post="confirmed"]')).toContainText('Confirmed +10')
|
||
await expect(card.locator('[data-post="removed"]')).toContainText('Removed')
|
||
await expect(card.locator('[data-post="review"]')).toContainText('Pending review')
|
||
await expect(card.locator('[data-slot="rewards-posts-pending"]')).toHaveText('+20 pending')
|
||
await expect(card.locator('[data-slot="rewards-disclosure"]')).toHaveText(rules.disclosure)
|
||
await expect(card.locator('[data-slot="rewards-posting"]')).toContainText(`${rules.share.days} days`)
|
||
await card.getByRole('textbox', { name: 'Address of your post' }).fill('https://x.com/ada/status/9')
|
||
await card.getByRole('button', { name: 'Add post' }).click()
|
||
await expect.poll(() => wrote.map((w) => w.body)).toEqual([{ url: 'https://x.com/ada/status/9' }])
|
||
})
|
||
|
||
test('Collection is every badge by shelf, unlocked from /me, its art from the cloud, and no claim while the contract is not live', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page)
|
||
await page.goto(`${base}/leaderboard#collection`)
|
||
const shelf = page.locator('[data-slot="rewards-collection"]')
|
||
await expect(shelf.locator('[data-badge]')).toHaveCount(rules.badges.length, { timeout: 20_000 })
|
||
await expect(shelf.locator('[data-earned]')).toHaveCount(EARNED.size)
|
||
await expect(shelf.locator('[data-group]')).toHaveCount(new Set(rules.badges.map((b) => b.group)).size)
|
||
await expect(shelf.locator('[data-badge="first-spark"]')).toHaveAttribute('data-earned', 'true')
|
||
await expect(shelf.locator('[data-badge="visionary"] [data-slot="rewards-badge-state"]')).toHaveText('Coming')
|
||
await expect(shelf.locator('[data-badge="model-master"] [data-slot="rewards-badge-state"]')).toHaveText('Locked')
|
||
expect(await shelf.locator('[data-badge="first-spark"] img').getAttribute('src')).toMatch(/\/v1\/rewards\/badges\/first-spark\/art$/)
|
||
await expect(shelf.locator('[data-slot="rewards-claim"]')).toHaveCount(0)
|
||
await expect(shelf.locator('[data-slot="rewards-chain"]')).toContainText('open when the badge contract is live')
|
||
await expect(shelf.locator('[data-slot="rewards-chain"]')).toContainText('A badge is a record of achievement, not an investment.')
|
||
})
|
||
|
||
test('once the rules say the contract is live, an unlocked badge can be claimed, and only an unlocked one', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page, { table: changed((r) => Object.assign(r.chain, { contract: '0x0000000000000000000000000000000000000b0b', minting: true })) })
|
||
const claimed: unknown[] = []
|
||
await page.route((u) => u.pathname === '/v1/rewards/claim', (r) => {
|
||
if (r.request().method() === 'POST') claimed.push(JSON.parse(r.request().postData() || '{}'))
|
||
return reply(r, {})
|
||
})
|
||
await page.goto(`${base}/leaderboard#collection`)
|
||
const shelf = page.locator('[data-slot="rewards-collection"]')
|
||
await expect(shelf.locator('[data-slot="rewards-claim"]')).toHaveCount(EARNED.size, { timeout: 20_000 })
|
||
const mine = shelf.locator('[data-badge="first-spark"] [data-slot="rewards-claim"]')
|
||
await mine.getByRole('textbox').fill('0x00000000000000000000000000000000000000a1')
|
||
await mine.getByRole('button', { name: 'Claim on hanzo.network' }).click()
|
||
await expect.poll(() => claimed).toEqual([{ badge: 'first-spark', wallet: '0x00000000000000000000000000000000000000a1' }])
|
||
})
|
||
|
||
test('Redeem turns whole dollars of eligible points into service credit, and says the cloud’s reason when it refuses', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page)
|
||
const asked: unknown[] = []
|
||
let refuse = false
|
||
let done: unknown[] = []
|
||
await page.route((u) => u.pathname === '/v1/rewards/redemptions', (r) => reply(r, { redemptions: done }))
|
||
await page.route((u) => u.pathname === '/v1/rewards/redeem', (r) => {
|
||
if (r.request().method() === 'OPTIONS') return reply(r, {})
|
||
asked.push(JSON.parse(r.request().postData() || '{}'))
|
||
if (refuse) return reply(r, { type: 'about:blank', title: 'Conflict', status: 409, detail: 'you can redeem $15 more this month' }, 409)
|
||
const got = { id: 'rdm_1', points: 500, cents: 500, status: 'granted', at: '2026-10-26T00:00:00Z', redeemable: 0, allowance: { monthCents: 2000, usedCents: 500, leftCents: 1500, open: true } }
|
||
done = [got]
|
||
return reply(r, got)
|
||
})
|
||
await page.goto(`${base}/leaderboard#redeem`)
|
||
const card = page.locator('[data-slot="rewards-redeem"]')
|
||
await expect(card.locator('[data-slot="rewards-redeemable"]')).toContainText('500', { timeout: 20_000 })
|
||
await expect(card.locator('[data-slot="rewards-allowance"]')).toContainText('$20.00')
|
||
await expect(card).toContainText('non-transferable Hanzo AI service credit')
|
||
await expect(card).toContainText('no cash value')
|
||
await expect(card).toContainText(`${rules.redeem.pointsPerDollar} points make $1.00`)
|
||
expect((await card.innerText()).toLowerCase()).not.toContain('payout')
|
||
const dollars = card.getByRole('textbox', { name: 'Dollars of credit to redeem' })
|
||
await dollars.fill('5')
|
||
await card.getByRole('button', { name: 'Redeem 500 points for $5.00' }).click()
|
||
await expect(card.locator('[data-slot="rewards-redeemed"]')).toHaveText('$5.00 of service credit is in your account.')
|
||
expect(asked).toEqual([{ dollars: 5 }])
|
||
await expect(card.locator('[data-slot="rewards-redemptions"] [role="listitem"]')).toHaveCount(1)
|
||
|
||
// A credit the commerce layer has not answered yet stays reserved: being granted.
|
||
done = [{ id: 'rdm_2', points: 200, cents: 200, status: 'reserved', at: '2026-10-26T01:00:00Z' }, ...done]
|
||
await page.reload()
|
||
await expect(card.locator('[data-slot="rewards-redemptions"] [role="listitem"]').first()).toContainText('Being granted', { timeout: 20_000 })
|
||
|
||
refuse = true
|
||
await dollars.fill('16')
|
||
await card.getByRole('button', { name: /^Redeem 1,600 points/ }).click()
|
||
await expect(card.locator('[data-slot="rewards-refused"]')).toHaveText('you can redeem $15 more this month')
|
||
})
|
||
|
||
test('a track only some members are invited to never shows its name to anyone else, and shows to the invited', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page)
|
||
for (const view of ['', '#earn', '#collection', '#leaderboard', '#redeem']) {
|
||
await page.goto(`${base}/leaderboard${view}`)
|
||
await page.locator('[data-slot="rewards-summary"]').waitFor({ timeout: 20_000 })
|
||
expect(await page.locator('body').innerText(), view || 'overview').not.toMatch(/\bcyber\b/i)
|
||
}
|
||
await expect(page.locator('[data-slot="rewards-track"]')).toHaveCount(0)
|
||
|
||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page, { track: { points: 120, level: { name: 'Operator', points: 100 }, next: { name: 'Red Team', points: 500 }, toNext: 380, badges: [{ id: 'first-packet', name: 'First Packet', earned: true, at: '' }], history: [] } })
|
||
await page.goto(`${base}/leaderboard`)
|
||
const track = page.locator('[data-slot="rewards-track"]')
|
||
await expect(track).toContainText('Operator', { timeout: 20_000 })
|
||
await expect(track).toContainText('380 points to Red Team')
|
||
})
|
||
|
||
test('/bounty prints the contribution rules, the cap, the claim steps, who is excluded and where a security report goes', async ({ page }) => {
|
||
await local(page)
|
||
await rewards(page)
|
||
await page.goto(`${base}/bounty`)
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Get points for improving Hanzo')
|
||
const contribution = rules.kinds.filter((k) => k.source === 'contributions')
|
||
await expect(page.locator('[data-slot="rewards-rules"] [data-kind]')).toHaveCount(contribution.length)
|
||
for (const id of ['bounty-starter', 'bounty-standard', 'bounty-advanced', 'fix']) await expect(page.locator(`[data-slot="rewards-rules"] [data-kind="${id}"]`)).toContainText(`+${rules.kinds.find((k) => k.id === id)?.points}`)
|
||
await expect(page.locator('[data-slot="rewards-cap"]')).toHaveText('Open source: at most 300 points a day.')
|
||
await expect(page.getByRole('heading', { name: 'Claim a bounty in four steps' })).toBeVisible()
|
||
await expect(page.locator('#main')).toContainText('Staff and bots of every participating org.')
|
||
await expect(page.locator('#main')).toContainText('Sourcing investors or financing.')
|
||
await expect(page.locator('main a[href="mailto:[email protected]"]')).toBeVisible()
|
||
await expect(page.locator('main a[href^="https://github.com/search?type=issues"]')).toBeVisible()
|
||
await expect(page.locator('[data-slot="bounty-mine"]').getByRole('link', { name: /Sign in to link GitHub/ })).toHaveAttribute('href', '/login?next=%2Fbounty')
|
||
})
|
||
|
||
test('/referral reads the rules signed out, and signed in adds Share and earn with the disclosure', async ({ page }) => {
|
||
await local(page)
|
||
await rewards(page)
|
||
await page.goto(`${base}/referral`)
|
||
const active = rules.kinds.find((k) => k.id === 'active')!.points
|
||
await expect(page.locator('[data-slot="referral-step"]').last()).toContainText(`${active} points when a builder from your link runs a first AI task`, { timeout: 20_000 })
|
||
await expect(page.locator('[data-slot="rewards-posts"]')).toHaveCount(0)
|
||
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await page.route('**/v1/marketing/beta', (r) => reply(r, { applied: true, program: 'beta', betas: [], actions: [] }))
|
||
await page.route('**/v1/flag/platform', (r) => reply(r, { flags: {} }))
|
||
await page.goto(`${base}/referral`)
|
||
const card = page.locator('[data-slot="rewards-posts"]')
|
||
await expect(card.locator('[data-post]')).toHaveCount(4, { timeout: 20_000 })
|
||
await expect(card.locator('[data-slot="rewards-disclosure"]')).toHaveText(rules.disclosure)
|
||
await expect(page.locator('[data-slot="referral-pending"]')).toHaveText('+20 pending')
|
||
// The composer's post carries the rules' disclosure line, on every channel.
|
||
await expect(page.locator('[data-slot="beta-disclosure"]')).toContainText(rules.disclosure)
|
||
const x = decodeURIComponent((await page.getByRole('link', { name: 'Share on X', exact: true }).first().getAttribute('href')) ?? '')
|
||
expect(x).toContain('I earn Hanzo Rewards points.')
|
||
})
|
||
|
||
for (const [name, size] of [
|
||
['phone', { width: 390, height: 844 }],
|
||
['desktop', { width: 1440, height: 900 }],
|
||
] as const) {
|
||
test(`on a ${name}, /leaderboard, /bounty and /referral fit the width`, async ({ page }) => {
|
||
await page.setViewportSize(size)
|
||
await local(page)
|
||
await signIn(page, { name: 'ada', email: '[email protected]' })
|
||
await rewards(page)
|
||
await page.route('**/v1/marketing/beta', (r) => reply(r, { applied: true, program: 'beta', betas: [], actions: [] }))
|
||
await page.route('**/v1/flag/platform', (r) => reply(r, { flags: {} }))
|
||
for (const view of ['', '#earn', '#collection', '#leaderboard', '#redeem']) {
|
||
await page.goto(`${base}/leaderboard${view}`)
|
||
await page.locator('[data-slot="rewards-summary"]').waitFor({ timeout: 20_000 })
|
||
await fits(page)
|
||
}
|
||
for (const path of ['/bounty', '/referral']) {
|
||
await page.goto(`${base}${path}`)
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
||
await fits(page)
|
||
}
|
||
})
|
||
}
|