Files
scrabble-game/ui/e2e/export.spec.ts
T
Ilia Denisov 8db18f2a2a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
fix(export): per-platform polish from the on-device review
- TG iOS: the OS share sheet with the fetched file (WKWebView has
  navigator.share — the долгоживущий debug-overlay Share path proves
  it). The chooser stays the app modal on this one platform: a native
  popup callback cannot supply the user activation the sheet needs.
  TG Android/desktop keep the native popup + downloadFile (verified
  perfect, untouched).
- VK mobile: the PNG now rides VKWebAppDownloadFile like the GCG — the
  owner-verified path that lands in VK's native share flow; the
  ShowImages viewer is dropped (its preview offered save only, no
  share).
- VK desktop iframe: both formats are plain anchor downloads (it is an
  ordinary browser; the viewer/bridge path made no sense there).
2026-07-02 23:05:35 +02:00

232 lines
10 KiB
TypeScript
Raw 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 { Buffer } from 'node:buffer';
import { expect, test, type Page } from './fixtures';
// The finished-game export: one signed relative URL (game.export_url) resolved against
// the app's own origin, delivered by the most native affordance per platform — Telegram's
// showPopup chooser + downloadFile dialog (all bridge calls, activation-safe), VK's
// native viewer/download, the OS share sheet with the fetched file on a mobile browser,
// a plain anchor download on desktop. 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';
// Buffer, not Uint8Array: route.fulfill silently hangs a FETCH interception on a
// Uint8Array body (a navigation download tolerates it) — the share test deadlocked.
const PNG_BYTES = Buffer.from(PNG_HEX, 'hex');
async function routeDl(page: Page): Promise<void> {
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<void> {
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: {},
platform: 'android',
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('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => {
// TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but
// it needs the user activation a native-popup callback cannot provide, so the chooser
// stays the app modal on this one platform.
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;
Object.assign(window, {
Telegram: {
WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: {},
platform: 'ios',
ready() {},
expand() {},
showPopup() {
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
},
downloadFile() {
(window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true;
},
},
},
});
});
await routeDl(page);
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();
// The app modal opened (both formats) — the native popup was not used…
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible();
await page.getByRole('button', { name: 'Image (PNG)' }).click();
// …and the artifact went to the OS share sheet, not TG's download dialog.
await expect
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
.toHaveLength(1);
const flags = await page.evaluate(() => {
const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean };
return { popup: !!w.__popupCalled, download: !!w.__downloadCalled };
});
expect(flags).toEqual({ popup: false, download: false });
});
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();
});