From 86ea1f45049b11cca28a8f81e194d4cf3885c2bd Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 20 Jan 2025 18:35:13 +0530 Subject: [PATCH] tests(e2e): add tests for "avoid overpaying"; add helpers --- tests/e2e/fixtures/base.ts | 8 +- tests/e2e/helpers/common.ts | 13 +- tests/e2e/overpaying.spec.ts | 235 +++++++++++++++++++++++++++++++++++ tests/e2e/pages/popup.ts | 5 + 4 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/overpaying.spec.ts diff --git a/tests/e2e/fixtures/base.ts b/tests/e2e/fixtures/base.ts index bd56b063..44d000f8 100644 --- a/tests/e2e/fixtures/base.ts +++ b/tests/e2e/fixtures/base.ts @@ -12,6 +12,7 @@ import { BrowserIntl, type Background, } from './helpers'; +import { getLastCallArg } from '../helpers/common'; import { openPopup, type Popup } from '../pages/popup'; import type { DeepPartial, Storage } from '@/shared/types'; @@ -156,6 +157,9 @@ export const expect = test.expect.extend({ expected: Record, { timeout = 5000 }: { timeout?: number } = {}, ) { + // Playwright doesn't let us extend to created generic matchers, so we'll + // typecast (as) in the way we need it. + type SpyFnTyped = SpyFn<[Record]>; const name = 'toHaveBeenLastCalledWithMatching'; let pass: boolean; @@ -164,11 +168,11 @@ export const expect = test.expect.extend({ try { // we only support matching first argument of last call await test.expect - .poll(() => fn.calls[fn.calls.length - 1][0], { timeout }) + .poll(() => getLastCallArg(fn as SpyFnTyped), { timeout }) .toMatchObject(expected); pass = true; } catch { - result = { actual: fn.calls[fn.calls.length - 1]?.[0] }; + result = { actual: getLastCallArg(fn as SpyFnTyped) }; pass = false; } diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index e93ed907..48d93171 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -1,6 +1,6 @@ import type { BrowserContext, Page } from '@playwright/test'; import type { ConnectDetails } from '../pages/popup'; -import { spy } from 'tinyspy'; +import { spy, type SpyFn } from 'tinyspy'; import { getWalletInformation } from '@/shared/helpers'; const OPEN_PAYMENTS_REDIRECT_URL = 'https://webmonetization.org/welcome'; @@ -66,9 +66,14 @@ export async function setupPlayground( ) { const monetizationCallback = spy<[window.MonetizationEvent], void>(); await page.exposeFunction('monetizationCallback', monetizationCallback); - await page.goto(playgroundUrl(...walletAddressUrls)); - await page.evaluate(() => { - window.addEventListener('monetization', monetizationCallback); + await page.addInitScript({ + content: `window.addEventListener('monetization', monetizationCallback)`, }); + await page.goto(playgroundUrl(...walletAddressUrls)); return monetizationCallback; } + +export function getLastCallArg(fn: SpyFn<[T]>) { + // we only deal with single arg functions + return fn.calls[fn.calls.length - 1][0]; +} diff --git a/tests/e2e/overpaying.spec.ts b/tests/e2e/overpaying.spec.ts new file mode 100644 index 00000000..395e1e0d --- /dev/null +++ b/tests/e2e/overpaying.spec.ts @@ -0,0 +1,235 @@ +import { spy } from 'tinyspy'; +import { test, expect } from './fixtures/connected'; +import { + getLastCallArg, + playgroundUrl, + setupPlayground, +} from './helpers/common'; +import { sendOneTimePayment } from './pages/popup'; +import type { OutgoingPayment } from '@interledger/open-payments'; + +test.beforeEach(async ({ popup }) => { + await popup.reload(); +}); + +test.afterEach(({ persistentContext: context }) => { + context.removeAllListeners('requestfinished'); +}); + +const walletAddressUrl = process.env.TEST_WALLET_ADDRESS_URL; + +test('should not pay immediately on page reload (overpaying)', async ({ + page, + popup, + persistentContext: context, +}) => { + const outgoingPaymentCreatedCallback = spy< + [{ id: string; receiver: string }], + void + >(); + context.on('requestfinished', async function intercept(req) { + if (!req.serviceWorker()) return; + if (req.method() !== 'POST') return; + if (!req.url().endsWith('/outgoing-payments')) return; + + const res = await req.response(); + if (!res) { + throw new Error('no response from POST /outgoing-payments'); + } + const outgoingPayment: OutgoingPayment = await res.json(); + + outgoingPaymentCreatedCallback({ + id: outgoingPayment.id, + receiver: outgoingPayment.receiver, + }); + }); + + const monetizationCallback = await setupPlayground(page, walletAddressUrl); + + await expect( + popup.getByTestId('home-page'), + 'site is shown as monetized', + ).toBeVisible(); + await expect(monetizationCallback).toHaveBeenCalledTimes(1); + await expect(outgoingPaymentCreatedCallback).toHaveBeenCalledTimes(1); + + await page.reload(); + await expect(popup.getByTestId('home-page')).toBeVisible(); + await page.waitForTimeout(3000); + await expect( + popup.getByTestId('home-page'), + 'site is shown as monetized', + ).toBeVisible(); + await expect( + monetizationCallback, + 'overpaying monetization event should be fired immediately', + ).toHaveBeenCalledTimes(2); + await expect( + outgoingPaymentCreatedCallback, + 'no new outgoing payment should be created', + ).toHaveBeenCalledTimes(1); + + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: outgoingPaymentCreatedCallback.calls[0][0].receiver, + }); + expect(monetizationCallback.calls[0][0].incomingPayment).toBe( + monetizationCallback.calls[1][0].incomingPayment, + ); + + await sendOneTimePayment(popup, '0.49'); + await page.waitForTimeout(2000); + await expect(monetizationCallback).toHaveBeenCalledTimes(3); + await expect( + outgoingPaymentCreatedCallback, + 'a single new outgoing payment should be created', + ).toHaveBeenCalledTimes(2); + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: getLastCallArg(outgoingPaymentCreatedCallback).receiver, + amountSent: { + value: expect.stringMatching(/^0.4\d$/), + }, + }); +}); + +test('should pay immediately on page navigation (clears overpaying)', async ({ + page, + popup, + persistentContext: context, +}) => { + const outgoingPaymentCreatedCallback = spy< + [{ id: string; receiver: string }], + void + >(); + context.on('requestfinished', async function intercept(req) { + if (!req.serviceWorker()) return; + if (req.method() !== 'POST') return; + if (!req.url().endsWith('/outgoing-payments')) return; + + const res = await req.response(); + if (!res) { + throw new Error('no response from POST /outgoing-payments'); + } + const outgoingPayment: OutgoingPayment = await res.json(); + + outgoingPaymentCreatedCallback({ + id: outgoingPayment.id, + receiver: outgoingPayment.receiver, + }); + }); + + const monetizationCallback = await setupPlayground(page, walletAddressUrl); + await expect(monetizationCallback).toHaveBeenCalledTimes(1); + await expect(outgoingPaymentCreatedCallback).toHaveBeenCalledTimes(1); + await expect( + popup.getByTestId('home-page'), + 'site is shown as monetized', + ).toBeVisible(); + + await page.goto('https://example.com'); + await expect(monetizationCallback).toHaveBeenCalledTimes(1); + await expect(outgoingPaymentCreatedCallback).toHaveBeenCalledTimes(1); + await expect( + popup.getByTestId('home-page'), + 'site is shown as not monetized', + ).not.toBeVisible(); + + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: outgoingPaymentCreatedCallback.calls[0][0].receiver, + }); + + await page.goto(playgroundUrl(walletAddressUrl)); + await expect(popup.getByTestId('home-page')).toBeVisible(); + await expect(monetizationCallback).toHaveBeenCalledTimes(2); + await expect(outgoingPaymentCreatedCallback).toHaveBeenCalledTimes(2); + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: outgoingPaymentCreatedCallback.calls[1][0].receiver, + }); + expect(monetizationCallback.calls[0][0].incomingPayment).not.toBe( + monetizationCallback.calls[1][0].incomingPayment, + ); + + await sendOneTimePayment(popup, '0.49'); + await page.waitForTimeout(2000); + await expect(monetizationCallback).toHaveBeenCalledTimes(3); + await expect( + outgoingPaymentCreatedCallback, + 'a single new outgoing payment should be created', + ).toHaveBeenCalledTimes(3); + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: getLastCallArg(outgoingPaymentCreatedCallback).receiver, + amountSent: { + value: expect.stringMatching(/^0.4\d$/), + }, + }); +}); + +test('should not pay immediately on page navigation - URL param change (overpaying)', async ({ + page, + popup, + persistentContext: context, +}) => { + const outgoingPaymentCreatedCallback = spy< + [{ id: string; receiver: string }], + void + >(); + context.on('requestfinished', async function intercept(req) { + if (!req.serviceWorker()) return; + if (req.method() !== 'POST') return; + if (!req.url().endsWith('/outgoing-payments')) return; + + const res = await req.response(); + if (!res) { + throw new Error('no response from POST /outgoing-payments'); + } + const outgoingPayment: OutgoingPayment = await res.json(); + + outgoingPaymentCreatedCallback({ + id: outgoingPayment.id, + receiver: outgoingPayment.receiver, + }); + }); + + const monetizationCallback = await setupPlayground(page, walletAddressUrl); + + await expect(monetizationCallback).toHaveBeenCalledTimes(1); + await expect(outgoingPaymentCreatedCallback).toHaveBeenCalledTimes(1); + await expect( + popup.getByTestId('home-page'), + 'site is shown as monetized', + ).toBeVisible(); + + const url = new URL(page.url()); + url.searchParams.append('foo', 'bar'); + await page.goto(url.href); + await expect(monetizationCallback).toHaveBeenCalledTimes(2); + await page.waitForTimeout(3000); + await expect( + outgoingPaymentCreatedCallback, + 'no new outgoing payment should be created', + ).toHaveBeenCalledTimes(1); + await expect( + popup.getByTestId('home-page'), + 'site is shown as monetized', + ).toBeVisible(); + + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: getLastCallArg(outgoingPaymentCreatedCallback).receiver, + }); + expect(monetizationCallback.calls[0][0].incomingPayment).toBe( + monetizationCallback.calls[1][0].incomingPayment, + ); + + await sendOneTimePayment(popup, '0.49'); + await page.waitForTimeout(2000); + await expect(monetizationCallback).toHaveBeenCalledTimes(3); + await expect( + outgoingPaymentCreatedCallback, + 'a single new outgoing payment should be created', + ).toHaveBeenCalledTimes(2); + await expect(monetizationCallback).toHaveBeenLastCalledWithMatching({ + incomingPayment: getLastCallArg(outgoingPaymentCreatedCallback).receiver, + amountSent: { + value: expect.stringMatching(/^0.4\d$/), + }, + }); +}); diff --git a/tests/e2e/pages/popup.ts b/tests/e2e/pages/popup.ts index fb97f194..c35abc65 100644 --- a/tests/e2e/pages/popup.ts +++ b/tests/e2e/pages/popup.ts @@ -88,3 +88,8 @@ export function getPopupFields(popup: Popup, i18n: BrowserIntl) { .getByText(i18n.getMessage('connectWallet_action_connect')), }; } + +export async function sendOneTimePayment(popup: Popup, amount: string) { + await popup.getByRole('textbox').fill(amount); + await popup.getByRole('button', { name: 'Send now' }).click(); +}