Files
hanzo.ai/e2e/gates/rewards.spec.ts

445 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}