Skip to content

Commit

Permalink
Merge branch 'main' into consistent-rate-of-pay
Browse files Browse the repository at this point in the history
  • Loading branch information
sidvishnoi authored Jan 21, 2025
2 parents e9d4994 + 9f68d39 commit 329cb94
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 7 deletions.
2 changes: 1 addition & 1 deletion src/background/services/tabEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class TabEvents {
* if loading and no url -> clear all sessions but not the overpaying state
* if loading and url -> we need to check if state keys include this url.
*/
if (changeInfo.status === 'loading' && changeInfo.url) {
if (changeInfo.status === 'loading') {
const url = tab.url ? removeQueryParams(tab.url) : '';
const clearOverpaying = this.tabState.shouldClearOverpaying(tabId, url);

Expand Down
8 changes: 6 additions & 2 deletions tests/e2e/fixtures/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -156,6 +157,9 @@ export const expect = test.expect.extend({
expected: Record<string, unknown>,
{ 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<string, string>]>;
const name = 'toHaveBeenLastCalledWithMatching';

let pass: boolean;
Expand All @@ -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;
}

Expand Down
13 changes: 9 additions & 4 deletions tests/e2e/helpers/common.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<T>(fn: SpyFn<[T]>) {
// we only deal with single arg functions
return fn.calls[fn.calls.length - 1][0];
}
237 changes: 237 additions & 0 deletions tests/e2e/overpaying.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
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.describe('should not pay immediately when overpaying', () => {
test('on page reload', 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('on page navigation - URL param change', 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$/),
},
});
});
});

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$/),
},
});
});
5 changes: 5 additions & 0 deletions tests/e2e/pages/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
4 changes: 4 additions & 0 deletions tests/e2e/special.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ test('iframe navigate does not de-monetize main page', async ({
});
});

test.fail(
true,
'https://github.com/interledger/web-monetization-extension/issues/819#issuecomment-2602266550',
);
await test.step('navigate iframe', async () => {
await page.evaluate(() => {
return new Promise((resolve, reject) => {
Expand Down

0 comments on commit 329cb94

Please sign in to comment.