Files
scrabble-game/ui/e2e/export.spec.ts
T
Ilia Denisov a9376c20a2
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m2s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
fix(e2e): Buffer body for the /dl route fulfill
route.fulfill silently never resolves a FETCH interception given a
Uint8Array body (a navigation download tolerates it), deadlocking the
new share-sheet test — the fetch promise hung, no share, no toast. A
node Buffer unblocks it. Also refreshes the spec header to the final
per-platform delivery matrix.
2026-07-02 22:30:54 +02:00

177 lines
7.8 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: {},
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();
});