import { expect, test, type Page } from './fixtures'; // The finished-game export: the history header's 📤 button opens the app's own format // chooser (never Telegram's native popup — its callback carries no user activation), // and BOTH formats travel the same unified route on every platform: the client mints a // signed relative URL (game.export_url), resolves it against its own origin and hands // it to the platform's native download — Telegram downloadFile / VKWebAppDownloadFile — // or a plain anchor download in a browser. g3 ("vs Rick") is the seeded finished game. // The mock gateway returns a shape-faithful /dl/... path; the tests intercept that // route and serve bytes, so the download itself is exercised end to end. // A minimal valid 1×1 PNG, hex-decoded without node typings (the e2e tsconfig has none). const PNG_HEX = '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' + '0000000a49444154789c6360000000020001e221bc330000000049454e44ae426082'; const PNG_BYTES = Uint8Array.from({ length: PNG_HEX.length / 2 }, (_, i) => parseInt(PNG_HEX.slice(i * 2, i * 2 + 2), 16), ); async function routeDl(page: Page): Promise { await page.route('**/dl/**', (route) => { const png = route.request().url().includes('/png'); return route.fulfill({ status: 200, contentType: png ? 'image/png' : 'text/plain; charset=utf-8', headers: { 'Content-Disposition': 'attachment' }, body: png ? PNG_BYTES : '#character-encoding UTF-8\n', }); }); } async function openFinishedHistory(page: Page): Promise { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await page.locator('.scoreboard').click(); await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); } test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => { await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Image (PNG)' }).click(); expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/); }); test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => { await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'GCG file' }).click(); expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); }); test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({ page, }) => { // A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays // in bridge calls — the popup picks the image, the artifact goes to the native download // dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.) await page.addInitScript(() => { Object.assign(window, { Telegram: { WebApp: { initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', initDataUnsafe: {}, ready() {}, expand() {}, showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) { const w = window as unknown as { __popup?: unknown }; w.__popup = params; setTimeout(() => cb('png'), 0); }, downloadFile(params: { url: string; file_name: string }) { const w = window as unknown as { __downloads?: { url: string; file_name: string }[] }; (w.__downloads ??= []).push(params); }, }, }, }); }); await page.goto('/'); await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await page.locator('.scoreboard').click(); await page.getByRole('button', { name: 'Export game' }).click(); // The native popup carried both formats plus cancel, and no in-app modal was mounted. const popup = await page.evaluate( () => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup, ); expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']); await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0); await expect .poll(() => page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads), ) .toHaveLength(1); const dl = await page.evaluate( () => (window as unknown as { __downloads: { url: string; file_name: string }[] }).__downloads[0], ); // The relative signed path was resolved against the app's own origin — absolute for TG. expect(dl.url).toMatch(/^http.+\/dl\/.+\/png\?/); expect(dl.file_name).toMatch(/^game-.+\.png$/); }); test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => { // Web Share with files present (a mobile browser): the artifact is fetched from the // signed URL and handed to navigator.share as a File — the sheet opens directly, // nothing lands in Downloads first. await page.addInitScript(() => { const shared: { name: string; type: string }[] = []; Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }); Object.defineProperty(navigator, 'share', { value: async (data: { files?: File[] }) => { for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type }); }, configurable: true, }); (window as unknown as { __shared: typeof shared }).__shared = shared; }); await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); await page.getByRole('button', { name: 'Image (PNG)' }).click(); await expect .poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared)) .toHaveLength(1); const shared = await page.evaluate( () => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0], ); expect(shared.name).toMatch(/^game-.+\.png$/); expect(shared.type).toBe('image/png'); }); test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { await page.addInitScript(() => { // Headless engines deny the real clipboard; a permissive stub keeps the legacy // copy path deterministic in both browsers. Object.defineProperty(navigator, 'clipboard', { value: { writeText: async () => {} }, configurable: true, }); Object.assign(window, { Telegram: { WebApp: { initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', initDataUnsafe: {}, ready() {}, expand() {}, }, }, }); }); await page.goto('/'); await expect(page.getByText('Your turn')).toBeVisible(); await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await page.locator('.scoreboard').click(); await page.getByRole('button', { name: 'Export game' }).click(); // No image option without the native download; the GCG stays available (clipboard path). await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0); await page.getByRole('button', { name: 'GCG file' }).click(); await expect(page.getByText('GCG copied to the clipboard.')).toBeVisible(); });