Files

490 lines
26 KiB
TypeScript

import { test, expect, type Browser, type BrowserContext, type Page, type Request, type Route } from '@playwright/test'
import { signIn, type Who } from './session'
/**
* A CHAT SHARED BY LINK, end to end against a dev server.
*
* pnpm dev -p 8084 &
* BASE_URL=http://localhost:8084 pnpm exec playwright test --project=chromium e2e/share.spec.ts
*
* The rule under test: a shared chat shows for its owner and for the people who
* open its link signed in, and for nobody else. A reader who is not signed in
* gets the title and a sign-in ask, never a turn of the chat. A signed-in reader
* is told who shared it and that their name will be shown, opens it with one
* press, is recorded as a viewer the owner can see and remove, and finds the
* chat under Shared with you. Revoking the link ends it for every viewer. The link's secret
* never leaves the browser except in the body of the one read that opens it.
*
* The platform is github.com/hanzoai/agent's share surface under /v1/agent/chat.
* With AGENT_URL set, every /v1/agent/chat call is forwarded to that server —
* the real package serving from its own store — with the gateway's identity
* headers written from the session the browser holds. Without it, one fake in
* this file plays the same contract. Every other /v1 call answers an empty 404
* and every other host is recorded and refused, so nothing leaves this machine.
*/
const AGENT = process.env.AGENT_URL?.replace(/\/+$/, '')
const ORIGIN = new URL(process.env.BASE_URL ?? 'http://localhost:8084').host
const TITLE = 'How does a hash table work'
const TURNS = [
{ role: 'user', content: TITLE },
{ role: 'assistant', content: 'A hash table is a coat check: the ticket says which hook.' },
{ role: 'user', content: 'And collisions?' },
{ role: 'assistant', content: 'Two coats, one hook: chain them, or try the next hook.' },
]
/** The last turn: the round's own answer, stored with the model that produced it
* (the harness answers every round with this sentence). */
const ANSWER = AGENT ? "the model's answer" : TURNS[3].content
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
const OWNER: Who = { sub: 'owner-1', name: 'Antje', email: '[email protected]', org: 'hanzo' }
const READER: Who = { sub: 'reader-1', name: 'New Reader', email: '[email protected]', org: 'newcomer' }
const SECOND: Who = { sub: 'reader-2', name: 'Zed', email: '[email protected]', org: 'acme' }
const STRANGER: Who = { sub: 'stranger-1', name: 'Quinn', email: '[email protected]', org: 'third' }
/** Who a request's bearer says it is: the session signIn put in the browser. */
function bearerOf(req: Request): { sub: string; name: string; email: string; org: string } | null {
const tok = (req.headers().authorization ?? '').replace(/^Bearer\s+/i, '')
const part = tok.split('.')[1]
if (!part) return null
const claims = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString()) as {
sub: string
name: string
email: string
orgs?: { org: string }[]
}
return { sub: claims.sub, name: claims.name, email: claims.email, org: claims.orgs?.[0]?.org ?? '' }
}
interface Row {
id: string
token: string
revoked: boolean
viewers: { id: string; sub: string; name: string; removed: boolean }[]
}
/** The platform, and the record of every request the pages made. */
class Platform {
thread = 'share-spec-thread'
rows: Row[] = []
seen: { url: string; body: string; method: string }[] = []
run = Math.random().toString(36).slice(2, 8)
/** A reader of this test alone: the real store outlives a test, so each test
* signs its readers in under subjects no other test used. */
as(w: Who): Who {
return { ...w, sub: `${w.sub}-${this.run}`, email: w.email!.replace('@', `+${this.run}@`) }
}
/** The owner's conversation: recorded in the real store, and its last turn the
* round's own answer, stored with the model that produced it — which it is
* only because the round is sent the whole stored transcript. */
async start(): Promise<void> {
if (!AGENT) return
const as = { 'Content-Type': 'application/json', 'X-Org-Id': 'hanzo', 'X-User-Id': OWNER.sub!, 'X-User-Name': OWNER.name!, 'X-User-Email': OWNER.email! }
const r = await fetch(`${AGENT}/v1/agent/chat/conversations`, { method: 'POST', headers: as, body: JSON.stringify({ messages: TURNS.slice(0, 2) }) })
this.thread = ((await r.json()) as { conversationId: string }).conversationId
await fetch(`${AGENT}/v1/agent/chat`, {
method: 'POST',
headers: as,
// The whole transcript, so the model's context is what a reader sees and
// its answer carries the model.
body: JSON.stringify({ preset: 'graph', conversationId: this.thread, messages: TURNS.slice(0, 3) }),
})
}
async serve(page: Page): Promise<void> {
page.on('request', (req) => this.seen.push({ url: req.url(), body: req.postData() ?? '', method: req.method() }))
await page.route(
(url) => url.host !== ORIGIN,
(route) => route.abort(),
)
await page.route(
(url) => url.pathname.startsWith('/v1/'),
(route) => route.fulfill({ status: 404, contentType: 'application/json', body: '{"detail":"not in this spec"}' }),
)
await page.route(
(url) => url.pathname === '/v1/billing/tier',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd', prepaidAvailable: 0 } }),
}),
)
await page.route(
(url) => url.pathname === '/v1/billing/balance',
(route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"available":0}' }),
)
await page.route(
(url) => url.pathname === '/v1/iam/oauth/userinfo',
(route) => route.fallback(),
)
await page.route(
(url) => url.pathname.startsWith('/v1/agent/chat/') || url.pathname.startsWith('/v1/agents/chat/'),
(route) => (AGENT ? this.forward(route) : this.fake(route)),
)
}
/** The real surface: the path singular, as cloud's router makes it, and the
* gateway's identity headers in place of the bearer. */
async forward(route: Route): Promise<void> {
const req = route.request()
const url = new URL(req.url())
const who = bearerOf(req)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (who) Object.assign(headers, { 'X-Org-Id': who.org, 'X-User-Id': who.sub, 'X-User-Name': who.name, 'X-User-Email': who.email })
const path = url.pathname.replace(/^\/v1\/agents\//, '/v1/agent/') + url.search
const r = await fetch(`${AGENT}${path}`, { method: req.method(), headers, body: req.postData() ?? undefined })
await route.fulfill({ status: r.status, contentType: 'application/json', body: await r.text() })
}
/** The same contract, held in memory. */
async fake(route: Route): Promise<void> {
const req = route.request()
const path = new URL(req.url()).pathname.replace(/^\/v1\/agents\//, '/v1/agent/')
const who = bearerOf(req)
const json = (status: number, body: unknown) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const owner = who?.sub === OWNER.sub
const out = (r: Row) => ({
id: r.id,
access: 'read',
createdAt: '2026-09-29T09:00:00Z',
viewers: r.viewers.filter((v) => !v.removed).map((v) => ({ id: v.id, name: v.name, openedAt: '2026-09-29T09:05:00Z' })),
})
const transcript = (r: Row) => ({
share: r.id,
title: TITLE,
by: OWNER.name,
access: 'read',
full: true,
messages: TURNS.map((t, i) => (i === 3 ? { ...t, model: 'zen-4' } : t)),
})
const conv = `/v1/agent/chat/conversations/${this.thread}`
if (path === conv && req.method() === 'GET') {
if (!owner) return json(404, { detail: 'conversation not found' })
return json(200, { conversationId: this.thread, messages: TURNS.map((t, i) => ({ id: `m${i}`, ...t })) })
}
if (path.startsWith(`${conv}/shares`)) {
if (!owner) return json(404, { detail: 'conversation not found' })
const rest = path.slice(`${conv}/shares`.length).split('/').filter(Boolean)
if (req.method() === 'POST' && rest.length === 0) {
const row: Row = { id: `share-${this.rows.length + 1}`, token: `Tk${this.rows.length + 1}${'x'.repeat(38)}9Q`, revoked: false, viewers: [] }
this.rows.push(row)
return json(201, { share: out(row), token: row.token })
}
if (req.method() === 'GET' && rest.length === 0) return json(200, { shares: this.rows.filter((r) => !r.revoked).map(out) })
const row = this.rows.find((r) => r.id === rest[0] && !r.revoked)
if (!row) return json(404, { detail: 'share not found' })
if (req.method() === 'DELETE' && rest.length === 1) {
row.revoked = true
row.viewers = []
return json(200, { id: row.id, revoked: true })
}
const v = row.viewers.find((x) => x.id === rest[2])
if (req.method() === 'DELETE' && rest[1] === 'viewers' && v) {
v.removed = true
return json(200, { id: v.id, removed: true })
}
return json(404, { detail: 'viewer not found' })
}
if (path === '/v1/agent/chat/shares/read') {
const { token, open } = JSON.parse(req.postData() || '{}') as { token?: string; open?: boolean }
const row = this.rows.find((r) => r.token === token && !r.revoked)
if (!row) return json(404, { detail: 'This chat is no longer shared.' })
if (!who) return json(200, { title: TITLE, access: 'read', full: false, messages: [] })
if (!owner) {
const had = row.viewers.find((v) => v.sub === who.sub)
if (had?.removed) return json(404, { detail: 'This chat is no longer shared.' })
if (!had && !open) return json(200, { title: TITLE, by: OWNER.name, access: 'read', full: false, confirm: true, messages: [] })
if (!had) row.viewers.push({ id: `viewer-${row.viewers.length + 1}`, sub: who.sub, name: who.name, removed: false })
}
return json(200, transcript(row))
}
if (path === '/v1/agent/chat/shared') {
if (!who) return json(403, { detail: 'a validated principal is required' })
const mine = this.rows.filter((r) => !r.revoked && r.viewers.some((v) => v.sub === who.sub && !v.removed))
return json(200, { shared: mine.map((r) => ({ share: r.id, title: TITLE, by: OWNER.name, openedAt: '2026-09-29T09:05:00Z' })) })
}
if (path.startsWith('/v1/agent/chat/shared/')) {
if (!who) return json(403, { detail: 'a validated principal is required' })
const row = this.rows.find((r) => r.id === path.split('/').pop() && !r.revoked)
if (!row || !(owner || row.viewers.some((v) => v.sub === who.sub && !v.removed))) return json(404, { detail: 'This chat is no longer shared.' })
return json(200, transcript(row))
}
if (path === '/v1/agent/chat/conversations' && req.method() === 'GET') {
return json(200, { conversations: owner ? [{ id: this.thread, title: TITLE, updatedAt: '2026-09-29T09:00:00Z' }] : [] })
}
return json(404, { detail: 'not in this spec' })
}
/** Every request, of any page, that carried s in its address or its body. */
carrying(s: string) {
return this.seen.filter((r) => r.url.includes(s) || r.body.includes(s))
}
}
/** A browser of its own, served by the platform, signed in as who (or nobody). */
async function visitor(browser: Browser, platform: Platform, who?: Who): Promise<{ ctx: BrowserContext; page: Page }> {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await platform.serve(page)
if (who) await signIn(page, who)
return { ctx, page }
}
/** Owner: open the conversation, open the panel, copy the link, and read it off the clipboard. */
async function shareAsOwner(context: BrowserContext, owner: Page, platform: Platform): Promise<string> {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await platform.serve(owner)
await signIn(owner, OWNER)
await owner.goto(`/chat?thread=${platform.thread}`)
await expect(owner.getByText('A hash table is a coat check', { exact: false })).toBeVisible({ timeout: 30_000 })
await owner.getByRole('button', { name: 'Share this conversation' }).click()
const panel = owner.getByRole('dialog', { name: 'Share this chat' })
await expect(panel).toBeVisible()
await expect(panel.getByText('No links yet')).toBeVisible()
await panel.getByRole('button', { name: 'Copy link' }).click()
await expect(panel.getByRole('button', { name: 'Link copied' })).toBeVisible()
await expect(panel.getByText('Nobody has opened it yet.')).toBeVisible()
await owner.screenshot({ path: `${SHOTS}/share-panel-copied.png` })
const copied = await owner.evaluate(() => navigator.clipboard.readText())
expect(copied).toMatch(/\/chat\/shared#[A-Za-z0-9_-]{43}$/)
expect(copied, 'the link carries the token, never the conversation id').not.toContain(platform.thread)
return copied
}
/** The one press a signed-in reader gives before opening a chat for the first time. */
async function agree(page: Page) {
const ask = page.getByRole('region', { name: 'Open the chat' })
await expect(ask).toBeVisible({ timeout: 30_000 })
await expect(ask.getByText(`${OWNER.name} shared this chat with you`)).toBeVisible()
await ask.getByRole('button', { name: `Open — ${OWNER.name} will see your name` }).click()
}
/** Close the owner's panel and open it again, which reads the links afresh. */
async function reopen(owner: Page) {
const panel = owner.getByRole('dialog', { name: 'Share this chat' })
if (await panel.isVisible()) await owner.keyboard.press('Escape')
await owner.getByRole('button', { name: 'Share this conversation' }).click()
await expect(panel).toBeVisible()
return panel
}
const tokenOf = (url: string) => new URL(url).hash.slice(1)
test.describe('a chat shared by link', () => {
test.describe.configure({ mode: 'serial', timeout: 180_000 })
test('a fresh room offers nothing to share', async ({ page: owner }) => {
const platform = new Platform()
await platform.serve(owner)
await signIn(owner, OWNER)
await owner.goto('/chat')
await expect(owner.getByRole('textbox').first()).toBeVisible({ timeout: 30_000 })
await expect(owner.getByRole('button', { name: 'Share this conversation' })).toHaveCount(0)
})
test('signed out: the title and a sign-in ask, no turn of the chat, and the secret stays on the device', async ({ browser, context, page: owner }) => {
const platform = new Platform()
await platform.start()
const token = tokenOf(await shareAsOwner(context, owner, platform))
const { ctx, page: reader } = await visitor(browser, platform)
await reader.goto(`/chat/shared#${token}`)
await expect(reader.getByRole('heading', { name: TITLE })).toBeVisible({ timeout: 30_000 })
await expect(reader.getByRole('region', { name: 'Sign in to read the chat' })).toBeVisible()
for (const said of [TURNS[1].content, TURNS[2].content, ANSWER]) await expect(reader.getByText(said, { exact: false })).toHaveCount(0)
await expect(reader.getByRole('list', { name: 'Messages' })).toHaveCount(0)
expect(reader.url(), 'the address loses the fragment before anything reads it').not.toContain('#')
await reader.screenshot({ path: `${SHOTS}/share-signed-out.png` })
// The analytics client flushes every 5 s and on the way out; then read
// everything that left the browser.
await reader.waitForTimeout(6_000)
await reader.close()
const events = platform.seen.filter((r) => new URL(r.url).pathname === '/v1/event')
expect(events.length, 'the analytics client ran on this page').toBeGreaterThan(0)
const carried = platform.carrying(token)
const away = platform.seen.filter((r) => new URL(r.url).host !== ORIGIN)
test.info().annotations.push({
type: 'network',
description: `${platform.seen.length} requests; ${events.length} to /v1/event; ${away.length} to other hosts (${[...new Set(away.map((r) => new URL(r.url).host))].join(', ')}); ${carried.length} carried the secret`,
})
expect(
carried.map((r) => `${r.method} ${new URL(r.url).host}${new URL(r.url).pathname}`),
'the secret left the browser only in the body of the read that opens it',
).toEqual(carried.map(() => `POST ${ORIGIN}/v1/agent/chat/shares/read`))
expect(carried.every((r) => !r.url.includes(token)), 'no address ever carried the secret').toBe(true)
await ctx.close()
})
test('a recipient signs in, reads it, and finds it under Shared with you; the owner sees them; a stranger cannot', async ({ browser, context, page: owner }) => {
const platform = new Platform()
await platform.start()
const token = tokenOf(await shareAsOwner(context, owner, platform))
const { ctx, page: reader } = await visitor(browser, platform)
await reader.goto(`/chat/shared#${token}`)
const ask = reader.getByRole('region', { name: 'Sign in to read the chat' })
await expect(ask).toBeVisible({ timeout: 30_000 })
await ask.getByRole('button', { name: 'Sign up to view' }).click()
await reader.waitForURL(/\/signup/)
expect(await reader.evaluate(() => sessionStorage.getItem('signin.destination')), 'the sign-in comes back without the secret in the address').toBe('/chat/shared')
expect(await reader.evaluate(() => sessionStorage.getItem('hanzo:share:open')), 'the tab holds the secret across the round trip').toBe(token)
// IAM finishes the sign-up; the site sends the signed-in reader on to where
// the sign-in was headed, the same `takeDestination` the callback reads.
await signIn(reader, platform.as(READER))
await reader.goto('/login')
await reader.waitForURL((u) => u.pathname === '/chat/shared', { timeout: 30_000 })
await expect(reader.getByText(ANSWER, { exact: false }), 'nothing is shown before the reader agrees').toHaveCount(0)
await reader.screenshot({ path: `${SHOTS}/share-confirm.png` })
await agree(reader)
await expect(reader.getByText(ANSWER, { exact: false })).toBeVisible({ timeout: 30_000 })
await expect(reader.getByText(`Shared by ${OWNER.name}`)).toBeVisible()
await expect(reader.getByText('Shared with you · Read only')).toBeVisible()
await expect(reader.getByText('Answered · zen-4')).toBeVisible()
await expect(reader.getByText('Recorded by the person who shared this').first()).toBeVisible()
await expect.poll(() => new URL(reader.url()).searchParams.get('s'), { message: 'the address names the share, not the secret' }).toBeTruthy()
const share = new URL(reader.url()).searchParams.get('s')!
expect(await reader.evaluate(() => sessionStorage.getItem('hanzo:share:open')), 'the secret is forgotten once read').toBeNull()
await reader.screenshot({ path: `${SHOTS}/share-after-signup.png` })
await reader.goto('/chat')
await expect(reader.getByText('Shared with you', { exact: true })).toBeVisible({ timeout: 30_000 })
await expect(reader.getByText(`From ${OWNER.name}`)).toBeVisible()
await reader.getByText(TITLE, { exact: true }).first().click()
await reader.waitForURL((u) => u.pathname === '/chat/shared' && u.searchParams.get('s') === share)
await expect(reader.getByText(ANSWER, { exact: false })).toBeVisible({ timeout: 30_000 })
await reader.screenshot({ path: `${SHOTS}/share-shared-with-you.png` })
const panel = await reopen(owner)
const opened = panel.getByRole('list', { name: 'Opened by' })
await expect(opened.getByText(READER.name!)).toBeVisible({ timeout: 10_000 })
await expect(opened.getByText(platform.as(READER).email!), 'the owner never sees the reader\'s email').toHaveCount(0)
await owner.screenshot({ path: `${SHOTS}/share-owner-sees-viewer.png` })
// A signed-in stranger with no link: nothing listed, nothing readable by id.
const { ctx: sctx, page: stranger } = await visitor(browser, platform, platform.as(STRANGER))
await stranger.goto('/chat')
await expect(stranger.getByRole('textbox').first()).toBeVisible({ timeout: 30_000 })
await expect(stranger.getByText('No chats yet', { exact: false })).toBeVisible({ timeout: 30_000 })
await expect(stranger.getByText('Shared with you', { exact: true })).toHaveCount(0)
await stranger.goto(`/chat/shared?s=${share}`)
await expect(stranger.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
await expect(stranger.getByText(ANSWER, { exact: false })).toHaveCount(0)
await stranger.screenshot({ path: `${SHOTS}/share-stranger.png` })
await sctx.close()
await ctx.close()
})
test('removing one viewer hides it from that one; revoking hides it from everyone', async ({ browser, context, page: owner }) => {
const platform = new Platform()
await platform.start()
const token = tokenOf(await shareAsOwner(context, owner, platform))
const open = async (who: Who) => {
const got = await visitor(browser, platform, who)
await got.page.goto(`/chat/shared#${token}`)
await agree(got.page)
await expect(got.page.getByText(ANSWER, { exact: false })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => new URL(got.page.url()).searchParams.get('s')).toBeTruthy()
return got
}
const first = await open(platform.as(READER))
const second = await open(platform.as(SECOND))
let panel = await reopen(owner)
const opened = panel.getByRole('list', { name: 'Opened by' })
await expect(opened.getByRole('listitem')).toHaveCount(2, { timeout: 10_000 })
await panel.getByRole('button', { name: `Remove ${SECOND.name}` }).click()
await expect(opened.getByRole('listitem')).toHaveCount(1)
await second.page.reload()
await expect(second.page.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
await second.page.goto(`/chat/shared#${token}`)
await expect(second.page.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
await first.page.reload()
await expect(first.page.getByText(ANSWER, { exact: false })).toBeVisible({ timeout: 30_000 })
panel = await reopen(owner)
await panel.getByRole('button', { name: /Revoke the link/ }).click()
await expect(panel.getByText('No links yet')).toBeVisible()
await first.page.reload()
await expect(first.page.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
await expect(first.page.getByText(ANSWER, { exact: false })).toHaveCount(0)
await first.page.screenshot({ path: `${SHOTS}/share-revoked.png` })
await first.page.goto('/chat')
await expect(first.page.getByText('No chats yet', { exact: false })).toBeVisible({ timeout: 30_000 })
await expect(first.page.getByText('Shared with you', { exact: true })).toHaveCount(0)
await first.page.goto(`/chat/shared#${token}`)
await expect(first.page.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
await first.ctx.close()
await second.ctx.close()
})
test('a link pasted into an open tab leaves the address before anything reads it', async ({ browser, context, page: owner }) => {
const platform = new Platform()
await platform.start()
const first = tokenOf(await shareAsOwner(context, owner, platform))
const panel = owner.getByRole('dialog', { name: 'Share this chat' })
await owner.keyboard.press('Escape')
await owner.getByRole('button', { name: 'Share this conversation' }).click()
await expect(panel).toBeVisible()
await panel.getByRole('button', { name: /Revoke the link/ }).click()
await expect(panel.getByText('No links yet')).toBeVisible()
await panel.getByRole('button', { name: 'Copy link' }).click()
await expect(panel.getByRole('button', { name: 'Link copied' })).toBeVisible()
const second = tokenOf(await owner.evaluate(() => navigator.clipboard.readText()))
expect(second).not.toBe(first)
const { ctx, page: reader } = await visitor(browser, platform)
await reader.goto('/chat/shared')
await expect(reader.getByRole('heading', { name: 'This chat is no longer shared.' })).toBeVisible({ timeout: 30_000 })
const from = platform.seen.length
await reader.evaluate((t) => {
location.hash = t
}, second)
await expect(reader.getByRole('heading', { name: TITLE })).toBeVisible({ timeout: 30_000 })
expect(reader.url(), 'the pasted fragment is gone from the address').not.toContain('#')
await reader.waitForTimeout(6_000)
await reader.close()
const carried = platform.seen.slice(from).filter((r) => r.url.includes(second) || r.body.includes(second))
expect(carried.map((r) => `${r.method} ${new URL(r.url).pathname}`)).toEqual(carried.map(() => 'POST /v1/agent/chat/shares/read'))
await ctx.close()
})
test('the panel fits a phone and is reachable by keyboard', async ({ context, page: owner }) => {
const platform = new Platform()
await platform.start()
await owner.setViewportSize({ width: 390, height: 844 })
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await platform.serve(owner)
await signIn(owner, OWNER)
await owner.goto(`/chat?thread=${platform.thread}`)
const control = owner.getByRole('button', { name: 'Share this conversation' })
await expect(control).toBeVisible({ timeout: 30_000 })
await control.focus()
await owner.keyboard.press('Enter')
const panel = owner.getByRole('dialog', { name: 'Share this chat' })
await expect(panel).toBeVisible()
const box = await panel.boundingBox()
expect(box).not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
await expect(panel.getByRole('button', { name: 'Copy link' })).toBeFocused()
await owner.keyboard.press('Enter')
await expect(panel.getByRole('button', { name: 'Link copied' })).toBeVisible()
await owner.screenshot({ path: `${SHOTS}/share-panel-390.png` })
await expect(panel.getByRole('button', { name: 'Copy link' })).toBeVisible({ timeout: 5_000 })
await panel.getByRole('button', { name: 'Copy link' }).click()
await expect(panel.getByRole('button', { name: 'Link copied' })).toBeVisible()
const made = platform.seen.filter((r) => r.method === 'POST' && new URL(r.url).pathname.endsWith('/shares'))
expect(made.length, 'a second copy reuses the held link').toBe(1)
await owner.keyboard.press('Escape')
await expect(panel).toBeHidden()
await expect(control).toBeFocused()
})
})