mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589fbf26aa | ||
|
|
8d5eed1cb7 | ||
|
|
a80c748c15 | ||
|
|
d60526fa72 | ||
|
|
704649f0c3 | ||
|
|
87c1013986 | ||
|
|
c0abf69aad | ||
|
|
2c819cc158 | ||
|
|
96f3c7d058 | ||
|
|
3809f38e5f | ||
|
|
89124bb27d | ||
|
|
eaff5c5929 | ||
|
|
f452d8d8fa | ||
|
|
23e56f2ce2 | ||
|
|
095c18fdb0 | ||
|
|
8c2b834fd3 | ||
|
|
2217513589 | ||
|
|
6379260042 | ||
|
|
9fb31abad4 | ||
|
|
c369b55841 | ||
|
|
01da03437a | ||
|
|
f67a04f67e | ||
|
|
908dcbbd22 | ||
|
|
1002f628a7 | ||
|
|
01135fcee4 | ||
|
|
e7f2f55b79 | ||
|
|
d98f16f191 | ||
|
|
bdfcbebd31 | ||
|
|
af01727f0f | ||
|
|
a0b1f82b85 | ||
|
|
225eeba6ec | ||
|
|
fcf8bc53d1 | ||
|
|
8b3746cdca | ||
|
|
737fdd2148 | ||
|
|
fb6571e914 | ||
|
|
3512093143 | ||
|
|
0b48fff418 | ||
|
|
8d1656dff8 | ||
|
|
ed21d14385 | ||
|
|
b2b9db0205 | ||
|
|
804e9c35e9 | ||
|
|
f14fa619bf | ||
|
|
d689861fa2 | ||
|
|
3984ec724c | ||
|
|
95634784a0 | ||
|
|
a028f2ce08 | ||
|
|
dceda8aa2a | ||
|
|
63f6ed7c38 |
No files matched your search
@@ -259,36 +259,6 @@ no user to name), puter.js opens the dashboard's upgrade page in a new tab with
|
||||
what was refused, through a consent dialog when the click has expired or the
|
||||
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
|
||||
|
||||
## Top-up
|
||||
|
||||
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
|
||||
buys add-on credit, served and framed like the upgrade: a transparent GUI page
|
||||
covering the host with no box or close button, allowing `payment` and
|
||||
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
|
||||
|
||||
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
|
||||
shows only when whoami reports `subscribed: true` and no `team` paying for the
|
||||
account. On a page that can't frame the GUI, the item opens the dashboard with
|
||||
`?topup=` in a new tab instead.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting, and the deployment defines
|
||||
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UITopUp({ onClose }).open();
|
||||
```
|
||||
|
||||
`open()` resolves once the dialog has loaded the account and shows what it can
|
||||
buy, and `onClose({ toppedUp })` must run exactly once, when the dialog closes
|
||||
or when it declines to open for the account. The GUI then posts `{ msg: 'puterPopupFrame', state: 'closed', toppedUp }`
|
||||
and the page removes the frame. When the frame can't show the top-up, puter.js
|
||||
opens the dashboard's top-up in a new tab, as it does the upgrade.
|
||||
|
||||
While the top-up shows, a call refused for funds still rejects but opens no
|
||||
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
|
||||
resolves `{ upgraded: false }` once the top-up closes.
|
||||
|
||||
## Email confirmation
|
||||
|
||||
`GET /embed/request-email-confirmation` walks a first-party page's user through
|
||||
@@ -329,7 +299,7 @@ Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -36,9 +36,9 @@ import type {
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade and
|
||||
* top-up dialogs, which it would otherwise open in a dashboard tab, and the
|
||||
* email confirmation, which it otherwise can't show.
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab, and the email confirmation,
|
||||
* which it otherwise can't show.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
@@ -46,7 +46,6 @@ export const FRAMED_POPUP_ACTIONS = [
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -414,7 +414,6 @@ describe('PuterServer host header validation', () => {
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
@@ -448,7 +447,7 @@ describe('PuterServer host header validation', () => {
|
||||
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Top-Up', '/embed/top-up/', '/embed/Request-Email-Confirmation'])(
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
|
||||
@@ -69,7 +69,6 @@ beforeAll(async () => {
|
||||
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
|
||||
'app-token-subscriber': { uuid: sessionUser, username: 'session-user', subscribed: true },
|
||||
};
|
||||
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
|
||||
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
|
||||
@@ -514,81 +513,6 @@ describe('embedded app browser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('offers Add credits to an account with a plan, and frames the top-up over the page', async () => {
|
||||
const context = await menuContext('app-token-subscriber');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
const item = page.locator('puter-user-menu .add-credits');
|
||||
await item.waitFor();
|
||||
expect(await item.textContent()).toBe('Add credits');
|
||||
// Under Usage, in the same row style as the links around it.
|
||||
expect(await page.locator('puter-user-menu .usage + .add-credits').count()).toBe(1);
|
||||
expect(await item.evaluate(el => getComputedStyle(el).height))
|
||||
.toBe(await page.locator('puter-user-menu .feedback').evaluate(el => getComputedStyle(el).height));
|
||||
|
||||
await item.click();
|
||||
const frame = page.locator('dialog.puter-popup-frame iframe');
|
||||
await frame.waitFor({ state: 'attached' });
|
||||
expect(await frame.getAttribute('src')).toBe(`${origin}/embed/top-up#user=${sessionUser}`);
|
||||
expect(await frame.getAttribute('allow')).toBe('payment; otp-credentials');
|
||||
expect(await page.locator('puter-user-menu .menu').isHidden()).toBe(true);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns keyboard focus to the account button when Add credits is chosen', async () => {
|
||||
const context = await menuContext('app-token-subscriber');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('puter-user-menu .add-credits').waitFor();
|
||||
await page.locator('puter-user-menu .add-credits').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('dialog.puter-popup-frame iframe').waitFor({ state: 'attached' });
|
||||
const focused = () => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className ?? document.activeElement.tagName);
|
||||
expect(await focused()).toBe('avatar');
|
||||
|
||||
// The dialog hands the focus back when it closes.
|
||||
const fromFrame = (state) => page.evaluate(([guiOrigin, frameState]) => {
|
||||
const source = document.querySelector('dialog.puter-popup-frame iframe').contentWindow;
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state: frameState }, origin: guiOrigin, source,
|
||||
}));
|
||||
}, [origin, state]);
|
||||
await fromFrame('ready');
|
||||
expect(await page.locator('dialog.puter-popup-frame').evaluate(el => el.open)).toBe(true);
|
||||
await fromFrame('closed');
|
||||
await page.locator('dialog.puter-popup-frame').waitFor({ state: 'detached' });
|
||||
expect(await focused()).toBe('avatar');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves Add credits out of the menu of an account without a plan', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
await page.locator('puter-user-menu .usage').waitFor();
|
||||
expect(await page.locator('puter-user-menu .add-credits').isHidden()).toBe(true);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the account menu when Tab leaves it', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,6 @@ export const FRAMED_POPUP_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
@@ -40,18 +39,17 @@ const HOST_USER_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions framed over the whole page with no box, which their own dialog's
|
||||
// scrim covers.
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', 'request-email-confirmation']);
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
|
||||
|
||||
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
|
||||
const UPGRADE_METHOD_MAX = 200;
|
||||
|
||||
// Modal UI the upgrade and top-up dialogs and the GUI's windows show. Once some has shown
|
||||
// Modal UI the upgrade dialog and the GUI's windows show. Once some has shown
|
||||
// and all of it has been gone this long, the dialog closed without reporting
|
||||
// it, and the frame mustn't be left covering the page.
|
||||
const UPGRADE_MODAL_UI = '.window, [aria-modal="true"]';
|
||||
@@ -253,23 +251,6 @@ export const canShowUpgradeFrame = (win, session) =>
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed top-up can show for the GUI session `session` (its
|
||||
* `/whoami`): the deployment has a top-up dialog that reports when it closes,
|
||||
* and no account check is waiting, which a frame couldn't be closed from.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {Record<string, unknown> | null} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canShowTopUpFrame = (win, session) =>
|
||||
win.UITopUp?.embeddable === true &&
|
||||
!!session &&
|
||||
!session.requires_email_confirmation &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed email confirmation can show for the GUI session `session`
|
||||
* (its `/whoami`): no check other than the email is waiting, since those show
|
||||
@@ -319,18 +300,19 @@ export const framedUpgradeDetails = (hash) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows one of the deployment's dialogs in a framed popup and tells the
|
||||
* Shows the deployment's upgrade dialog in a framed popup and tells the
|
||||
* framing page it's ready once it's open, or before it opens when the page
|
||||
* still hides the frame. Resolves what the dialog reports when it closes, or
|
||||
* false once every dialog and window it showed has gone without it reporting
|
||||
* a close.
|
||||
* still hides the frame. Resolves whether the user upgraded,
|
||||
* once the dialog closes, or false once every dialog and window it showed has
|
||||
* gone without it reporting a close.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {(closed: (result: boolean) => void) => unknown} open - opens the dialog, which calls `closed` when it closes
|
||||
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
|
||||
* an empty one is passed as null, so the dialog credits the prompt it remembers
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const showFramedDialog = (win, open, tell) => new Promise((resolve, reject) => {
|
||||
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let seen = false;
|
||||
let goneTimer = null;
|
||||
@@ -363,47 +345,21 @@ const showFramedDialog = (win, open, tell) => new Promise((resolve, reject) => {
|
||||
toldReady = true;
|
||||
tell('ready');
|
||||
};
|
||||
const open = () => {
|
||||
const dialog = new win.UIUpgradeAccount({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => settle(() => resolve(result?.upgraded === true)),
|
||||
});
|
||||
return dialog.open_as_window();
|
||||
};
|
||||
whenFrameSized(win, ready)
|
||||
.then(() => open((result) => settle(() => resolve(result))))
|
||||
.then(open)
|
||||
.then(() => {
|
||||
check();
|
||||
ready();
|
||||
}, (e) => settle(() => reject(e)));
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the deployment's upgrade dialog in a framed popup. Resolves whether
|
||||
* the user upgraded, once the dialog closes.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
|
||||
* an empty one is passed as null, so the dialog credits the prompt it remembers
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(win, (closed) => {
|
||||
const dialog = new win.UIUpgradeAccount({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => closed(result?.upgraded === true),
|
||||
});
|
||||
return dialog.open_as_window();
|
||||
}, tell);
|
||||
|
||||
/**
|
||||
* Shows the deployment's top-up dialog in a framed popup. Resolves whether
|
||||
* the user bought credit, once the dialog closes.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
|
||||
const dialog = new win.UITopUp({
|
||||
onClose: (result) => closed(result?.toppedUp === true),
|
||||
});
|
||||
return dialog.open();
|
||||
}, tell);
|
||||
|
||||
/**
|
||||
* Resolves once the frame has a viewport. The page keeps a loading frame
|
||||
* hidden, which Chromium and WebKit lay out at 0x0, and a GUI window opened
|
||||
|
||||
@@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
POPUP_FRAME_MSG,
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowTopUpFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedTopUp,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './popupFrame.js';
|
||||
@@ -52,7 +50,6 @@ describe('framedPopupAction', () => {
|
||||
['/embed/show-save-file-picker', 'show-save-file-picker'],
|
||||
['/embed/show-directory-picker', 'show-directory-picker'],
|
||||
['/embed/upgrade', 'upgrade'],
|
||||
['/embed/top-up', 'top-up'],
|
||||
['/embed/request-email-confirmation', 'request-email-confirmation'],
|
||||
['/embed/apps', null],
|
||||
['/embed/feedback', null],
|
||||
@@ -88,7 +85,6 @@ describe('framesHostUser', () => {
|
||||
['show-save-file-picker', true],
|
||||
['show-directory-picker', true],
|
||||
['upgrade', true],
|
||||
['top-up', true],
|
||||
['request-email-confirmation', true],
|
||||
['sign-in', false],
|
||||
[null, false],
|
||||
@@ -137,34 +133,6 @@ describe('canShowUpgradeFrame', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowTopUpFrame', () => {
|
||||
const win = { UITopUp: Object.assign(function () {}, { embeddable: true }) };
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
it('shows for a session with nothing to verify, when the dialog reports its close', () => {
|
||||
expect(canShowTopUpFrame(win, session)).toBe(true);
|
||||
});
|
||||
|
||||
it('needs a top-up dialog that reports its close, whatever the upgrade dialog does', () => {
|
||||
expect(canShowTopUpFrame({}, session)).toBe(false);
|
||||
expect(canShowTopUpFrame({ UITopUp: function () {} }, session)).toBe(false);
|
||||
expect(canShowTopUpFrame({ UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) }, session)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'requires_email_confirmation',
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
])('leaves an account with %s to the tab', (check) => {
|
||||
expect(canShowTopUpFrame(win, { ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowTopUpFrame(win, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowEmailConfirmationFrame', () => {
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
@@ -393,64 +361,6 @@ describe('showFramedUpgrade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFramedTopUp', () => {
|
||||
const topUpWindow = (open) => {
|
||||
const events = new EventTarget();
|
||||
return {
|
||||
document,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
MutationObserver: window.MutationObserver,
|
||||
UITopUp: function (options) {
|
||||
this.open = () => open(options);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tells the page it is ready once open, and resolves whether credit was bought', async () => {
|
||||
const tell = vi.fn();
|
||||
let options;
|
||||
const result = showFramedTopUp(topUpWindow(async (o) => {
|
||||
options = o;
|
||||
}), tell);
|
||||
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
options.onClose({ toppedUp: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('resolves false for a close without a purchase', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false once the dialog is gone without reporting a close', async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
const result = showFramedTopUp(topUpWindow(async () => {
|
||||
document.body.append(root);
|
||||
}), vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
root.remove();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the dialog fails to open', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async () => {
|
||||
throw new Error('no dialog');
|
||||
}), vi.fn())).rejects.toThrow('no dialog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPopupFrame', () => {
|
||||
it('does nothing without a framing page to answer', () => {
|
||||
const win = frameWindow();
|
||||
@@ -486,11 +396,6 @@ describe('startPopupFrame', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the framing page show through a top-up frame', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/top-up' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the framing page show through an upgrade or email confirmation frame only', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
|
||||
|
||||
@@ -49,7 +49,6 @@ import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import { embedHostUser } from './helpers/embedHostUser.js';
|
||||
import {
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowTopUpFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
@@ -58,7 +57,6 @@ import {
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedTopUp,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './helpers/popupFrame.js';
|
||||
@@ -1050,23 +1048,6 @@ const postAuthActions = async (action) => {
|
||||
window.tell_popup_frame('closed', { upgraded });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
|
||||
// which hears whether the user bought credit once the dialog closes.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'top-up' && window.embedded_in_frame ) {
|
||||
let toppedUp;
|
||||
try {
|
||||
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
|
||||
} catch (e) {
|
||||
// A dialog that failed to open leaves the page to open the top-up in a tab.
|
||||
console.error('top-up action failed', e);
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
window.tell_popup_frame('closed', { toppedUp });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
|
||||
// first-party page framing it, which hears whether the email is confirmed once the
|
||||
@@ -1860,7 +1841,6 @@ window.initgui = async function (options) {
|
||||
!host_user ||
|
||||
session_user !== host_user ||
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
|
||||
(action === 'top-up' && !canShowTopUpFrame(window, session)) ||
|
||||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
|
||||
) {
|
||||
window.tell_popup_frame('unavailable');
|
||||
|
||||
@@ -297,9 +297,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
const removal = new MutationObserver(() => {
|
||||
if ( ! dialog.isConnected ) dismiss();
|
||||
});
|
||||
// Down through shadow roots: a component's host can't take the focus back itself.
|
||||
let restoreFocus = document.activeElement;
|
||||
while ( restoreFocus?.shadowRoot?.activeElement ) restoreFocus = restoreFocus.shadowRoot.activeElement;
|
||||
const restoreFocus = document.activeElement;
|
||||
const wait = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => finish(null), ms);
|
||||
@@ -456,13 +454,12 @@ const POPUP_FRAME_BOXES = {
|
||||
'show-directory-picker': { width: 700, height: 400 },
|
||||
};
|
||||
|
||||
// The upgrade and the top-up have no box: each covers the page and its dialog
|
||||
// draws its own scrim. A payment in it may use the Payment Request API, which
|
||||
// a frame can use only when allowed, and Escape is left to the dialog, which
|
||||
// doesn't close mid-entry.
|
||||
// The upgrade has no box: it covers the page and its dialog draws its own
|
||||
// scrim. A payment in it may use the Payment Request API, which a frame can
|
||||
// use only when allowed, and Escape is left to the dialog, which doesn't close
|
||||
// mid-entry.
|
||||
const UNBOXED_POPUP_FRAMES = {
|
||||
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
'top-up': { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -479,8 +476,8 @@ const UNBOXED_POPUP_FRAMES = {
|
||||
* @template T
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade, the top-up or the email confirmation
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
|
||||
* @param {string} opts.title - the frame's accessible name
|
||||
* @param {string} [opts.closeLabel] - puts a close button on the frame
|
||||
* @param {(data?: Record<string, unknown>) => T} opts.closed
|
||||
|
||||
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
|
||||
*
|
||||
* @typedef {{
|
||||
* env?: string,
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean, topUpShowing_?: () => boolean },
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
|
||||
* authToken?: string,
|
||||
* appID?: string,
|
||||
* getAppIDFromAuthToken?: (token: unknown) => string | null,
|
||||
@@ -129,9 +129,8 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
puter.ui.requestUpgrade({ reason, method, message });
|
||||
} else if ( puter?.env === 'web' || reason === 'storage' ) {
|
||||
// The upgrade a first-party page already shows answers this refusal
|
||||
// too, and a dialog over it, or over a top-up, would block it.
|
||||
// too, and a dialog over it would block it.
|
||||
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
|
||||
if ( puter.ui?.topUpShowing_?.() === true ) return;
|
||||
// A first-party page shows the upgrade in a frame over itself
|
||||
// instead of a dashboard tab.
|
||||
const framed = puter?.env === 'web' && puter.ui?.canShowUpgradeFrame_?.() === true;
|
||||
|
||||
@@ -217,14 +217,6 @@ describe('promptIfUpgradeRequired', () => {
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no dialog over a top-up the page already shows', () => {
|
||||
globalThis.puter.ui.topUpShowing_ = () => true;
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog).not.toHaveBeenCalled();
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the tab when the page cannot frame it', () => {
|
||||
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
|
||||
@@ -5,7 +5,7 @@ import PuterDialog from './PuterDialog.js';
|
||||
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
|
||||
import { hasUserActivation } from '../lib/auth-popup.js';
|
||||
import { appIDOf } from '../lib/upgradePrompt.js';
|
||||
import { topUpUrl, upgradeUrl } from './UsageLimitDialog.js';
|
||||
import { upgradeUrl } from './UsageLimitDialog.js';
|
||||
import { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
@@ -538,9 +538,6 @@ export class UIModule extends EventListener {
|
||||
// The framed upgrade showing, which a call made while it's open joins.
|
||||
#upgradeFrame = null;
|
||||
|
||||
// The top-up showing, framed or about to open in a tab, which a call made meanwhile joins.
|
||||
#topUpFrame = null;
|
||||
|
||||
// The framed email confirmation showing, which a call made while it's open joins.
|
||||
#emailConfirmationFrame = null;
|
||||
|
||||
@@ -1806,20 +1803,13 @@ export class UIModule extends EventListener {
|
||||
/**
|
||||
* The upgrade in a frame over the page, for the page's signed-in user, or
|
||||
* in a new tab when the frame can't show it. A call made while one is
|
||||
* showing settles with it, so a payment in progress isn't replaced, and
|
||||
* one made while the top-up shows resolves `{ upgraded: false }` once
|
||||
* that closes.
|
||||
* showing settles with it, so a payment in progress isn't replaced.
|
||||
*
|
||||
* @param {{ reason?: string, method?: string }} details
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
#showUpgradeFrame ({ reason, method }) {
|
||||
if ( this.#upgradeFrame ) return this.#upgradeFrame;
|
||||
// A second full-page frame would cover the top-up, and a payment in it.
|
||||
if ( this.#topUpFrame ) {
|
||||
const notUpgraded = () => ({ upgraded: false });
|
||||
return this.#topUpFrame.then(notUpgraded, notUpgraded);
|
||||
}
|
||||
this.#upgradeFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
@@ -1835,9 +1825,6 @@ export class UIModule extends EventListener {
|
||||
closed: (data) => ({ upgraded: data?.upgraded === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
// The page's cached whoami still reads it as having no plan,
|
||||
// which is what the user menu offers Add credits by.
|
||||
if ( framed?.upgraded ) user.subscribed = true;
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
|
||||
return { upgraded: false };
|
||||
@@ -1849,65 +1836,14 @@ export class UIModule extends EventListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link showTopUp_} is showing the top-up in a frame, about to,
|
||||
* or asking to open it in a tab instead.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
topUpShowing_ () {
|
||||
return this.#topUpFrame !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the top-up flow, where a user with a plan buys add-on credit. On
|
||||
* a signed-in first-party page one label under the Puter origin, it shows
|
||||
* in a frame over the page, and the promise resolves once it closes. When
|
||||
* the frame can't show it, and on any other website, it opens on the
|
||||
* dashboard in a new tab and the promise resolves `{ toppedUp: false }`.
|
||||
* A call made while one is showing settles with it, so a payment in
|
||||
* progress isn't replaced.
|
||||
*
|
||||
* @internal
|
||||
* @returns {Promise<{ toppedUp: boolean }>}
|
||||
*/
|
||||
showTopUp_ () {
|
||||
if ( this.#topUpFrame ) return this.#topUpFrame;
|
||||
const showing = (async () => {
|
||||
// Not awaited where no frame can show, so the tab opens within the click.
|
||||
const user = this.#canFrameForUser() ? await this.#frameUser() : null;
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'top-up',
|
||||
fragment: { user: user.uuid },
|
||||
title: 'Add credits',
|
||||
closed: (data) => ({ toppedUp: data?.toppedUp === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
|
||||
return { toppedUp: false };
|
||||
})();
|
||||
this.#topUpFrame = showing;
|
||||
// Cleared from outside: the steps above can fail before their first
|
||||
// await, which is before the field is set.
|
||||
const done = () => {
|
||||
if ( this.#topUpFrame === showing ) this.#topUpFrame = null;
|
||||
};
|
||||
showing.then(done, done);
|
||||
return showing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the upgrade or top-up page in a new tab. The click that asked for
|
||||
* it may have expired by now, so without it the user opens it from a
|
||||
* consent dialog. Resolves once the tab opens or the user answers the dialog.
|
||||
* Opens the upgrade page in a new tab. The click that asked for it may
|
||||
* have expired by now, so without it the user opens it from a consent
|
||||
* dialog. Resolves once the tab opens or the user answers the dialog.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} [message] - what the consent dialog asks
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
#openUpgradeTab (url, message = 'This website wants to open the Puter upgrade page.') {
|
||||
#openUpgradeTab (url) {
|
||||
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let consent = null;
|
||||
@@ -1917,7 +1853,7 @@ export class UIModule extends EventListener {
|
||||
};
|
||||
try {
|
||||
consent = PuterDialog.show({
|
||||
message,
|
||||
message: 'This website wants to open the Puter upgrade page.',
|
||||
onContinue: () => {
|
||||
window.open(url, '_blank');
|
||||
answered();
|
||||
|
||||
@@ -87,26 +87,6 @@ describe('ui.requestUpgrade on a signed-in first-party page', () => {
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the page\'s cached account as having a plan once the user upgrades', async () => {
|
||||
const account = { uuid: USER, subscribed: false };
|
||||
puter.whoamiCache_ = Promise.resolve(account);
|
||||
const ui = makeUI();
|
||||
|
||||
let result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await result;
|
||||
expect(account.subscribed).toBe(false);
|
||||
|
||||
result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
|
||||
await result;
|
||||
expect(account.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('says the upgrade is showing from the call until it closes', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { UIModule } = await import('./UI.js');
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
const GUI_ORIGIN = 'https://puter.test';
|
||||
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
// On the deployment the page belongs to, where the frame would have shown it.
|
||||
const TOP_UP_TAB = `${GUI_ORIGIN}/dashboard/?topup=#usage`;
|
||||
|
||||
const topUpDialog = () => document.querySelector('dialog.puter-popup-frame');
|
||||
const topUpFrame = () => topUpDialog()?.querySelector('iframe') ?? null;
|
||||
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(topUpFrame().src).hash.slice(1)));
|
||||
|
||||
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data, origin: GUI_ORIGIN, source: topUpFrame().contentWindow,
|
||||
}));
|
||||
|
||||
const makeUI = (env = 'web') => new UIModule(
|
||||
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', util: {} },
|
||||
{ appInstanceID: 'instance-1' },
|
||||
);
|
||||
|
||||
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
|
||||
globalThis.puter = {
|
||||
defaultGUIOrigin: guiOrigin,
|
||||
whoamiCache_: Promise.resolve({ uuid: USER }),
|
||||
auth: {
|
||||
isSignedIn: () => signedIn,
|
||||
getUser: vi.fn(async () => ({ uuid: USER })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setActivation = (isActive) => Object.defineProperty(navigator, 'userActivation', {
|
||||
configurable: true,
|
||||
value: { hasBeenActive: true, isActive },
|
||||
});
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
window.open = vi.fn(() => ({ closed: false }));
|
||||
setActivation(true);
|
||||
fakePuter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete navigator.userActivation;
|
||||
delete globalThis.puter;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ that fails before anything shows', () => {
|
||||
it('stops saying a top-up is showing, and tries again on the next call', async () => {
|
||||
// No scheme, so the dashboard's address can't be built.
|
||||
fakePuter({ guiOrigin: 'puter.test' });
|
||||
const ui = makeUI();
|
||||
await expect(ui.showTopUp_()).rejects.toThrow();
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
|
||||
fakePuter();
|
||||
const result = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ on a signed-in first-party page', () => {
|
||||
it('shows the top-up in a frame over the page and resolves what the dialog reports', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
|
||||
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
|
||||
expect(fragment()).toEqual({ user: USER });
|
||||
// It covers the page, and the dialog inside draws the scrim.
|
||||
expect(topUpDialog().querySelector('.puter-popup-frame-box')).toBeNull();
|
||||
expect(topUpDialog().querySelector('.puter-popup-frame-close')).toBeNull();
|
||||
expect(topUpFrame().getAttribute('allow')).toBe('payment; otp-credentials');
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(topUpDialog().open).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
|
||||
await expect(result).resolves.toEqual({ toppedUp: true });
|
||||
expect(topUpFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves toppedUp false when the dialog closes without a purchase', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
|
||||
it('says a top-up is showing until it closes', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
const result = ui.showTopUp_();
|
||||
expect(ui.topUpShowing_()).toBe(true);
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(ui.topUpShowing_()).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await result;
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Escape on the page to the dialog once it shows', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
|
||||
topUpDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
|
||||
expect(topUpFrame()).not.toBeNull();
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: false });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
|
||||
it('settles a call made while the top-up shows with it, without replacing it', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
const frame = topUpFrame();
|
||||
|
||||
const second = ui.showTopUp_();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
await expect(second).resolves.toEqual({ toppedUp: true });
|
||||
|
||||
// A later call shows a new one.
|
||||
ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
expect(topUpFrame()).not.toBe(frame);
|
||||
});
|
||||
|
||||
it('opens the dashboard top-up in a tab when the frame cannot show it', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
expect(topUpFrame()).toBeNull();
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
|
||||
});
|
||||
|
||||
it('asks for a click before opening the tab once the original click has expired', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
setActivation(false);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
|
||||
|
||||
const consent = document.querySelector('puter-dialog');
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(consent.shadowRoot.querySelector('.about').textContent)
|
||||
.toBe('This website wants to open the Puter top-up page.');
|
||||
|
||||
consent.shadowRoot.querySelector('#launch-auth-popup').click();
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ elsewhere on the web', () => {
|
||||
it.each([
|
||||
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
|
||||
['a signed-out first-party page', { signedIn: false }],
|
||||
])('opens the dashboard top-up in a tab within the click on %s', async (_label, options) => {
|
||||
fakePuter(options);
|
||||
const result = makeUI().showTopUp_();
|
||||
|
||||
// Before anything is awaited, so the browser still counts the click.
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(window.open).toHaveBeenCalledWith(`${globalThis.puter.defaultGUIOrigin}/dashboard/?topup=#usage`, '_blank');
|
||||
expect(topUpFrame()).toBeNull();
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestUpgrade beside the top-up', () => {
|
||||
it('waits for the top-up showing instead of framing the upgrade over it', async () => {
|
||||
const ui = makeUI();
|
||||
const toppingUp = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
|
||||
const upgrading = ui.requestUpgrade({ reason: 'funds' });
|
||||
await expect(settled(upgrading)).resolves.toBe(PENDING);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
await expect(toppingUp).resolves.toEqual({ toppedUp: true });
|
||||
await expect(upgrading).resolves.toEqual({ upgraded: false });
|
||||
expect(document.querySelector('dialog.puter-popup-frame')).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still asks before opening the upgrade tab in its own words', async () => {
|
||||
const result = makeUI().requestUpgrade({ reason: 'funds' });
|
||||
const frame = () => document.querySelector('dialog.puter-popup-frame iframe');
|
||||
await vi.waitFor(() => expect(frame()).not.toBeNull());
|
||||
expect(new URL(frame().src).pathname).toBe('/embed/upgrade');
|
||||
setActivation(false);
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state: 'unavailable' }, origin: GUI_ORIGIN, source: frame().contentWindow,
|
||||
}));
|
||||
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
|
||||
const consent = document.querySelector('puter-dialog');
|
||||
expect(consent.shadowRoot.querySelector('.about').textContent)
|
||||
.toBe('This website wants to open the Puter upgrade page.');
|
||||
consent.shadowRoot.querySelector('#launch-auth-popup-cancel').click();
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
});
|
||||
});
|
||||
@@ -28,19 +28,6 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
|
||||
return url.href;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard page that opens the top-up flow, for buying add-on credit.
|
||||
*
|
||||
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const topUpUrl = (guiOrigin) => {
|
||||
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
|
||||
url.searchParams.set('topup', '');
|
||||
url.hash = 'usage';
|
||||
return url.href;
|
||||
};
|
||||
|
||||
class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
|
||||
/**
|
||||
* @param {string} message Plain-text line saying what was refused.
|
||||
|
||||
@@ -343,7 +343,6 @@ The account button and Puter's app browser for a first-party app on a Puter subd
|
||||
- Signed in, it shows the user's profile picture, or the username's initial when there is none. Clicking it opens a menu with:
|
||||
- The user's picture, username and email. The email line is hidden when whoami leaves it out, such as for an app that can't read it.
|
||||
- **Manage account**, **Usage** and **Open Puter**, links that open the GUI origin's `/dashboard/#account`, `/dashboard/#usage` and `/` in a new tab.
|
||||
- **Add credits**, under Usage, only when whoami reports `subscribed: true` and no `team` that pays for the account. It shows the top-up dialog in a frame over the page (`/embed/top-up`) on a page that can frame the GUI, and otherwise opens it on the dashboard in a new tab.
|
||||
- **Send feedback**, which calls `puter.ui.showFeedbackDialog()`.
|
||||
- **Sign out**, which calls `puter.auth.signOut()`.
|
||||
- The menu opens with focus on its first item. ArrowUp and ArrowDown move between items and wrap around, and Home and End jump to the first and last.
|
||||
@@ -363,7 +362,6 @@ The account button and Puter's app browser for a first-party app on a Puter subd
|
||||
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
|
||||
| `manage-account-label` | `Manage account` | Menu link to the account settings |
|
||||
| `usage-label` | `Usage` | Menu link to the usage page |
|
||||
| `add-credits-label` | `Add credits` | Menu item that opens the top-up dialog |
|
||||
| `open-puter-label` | `Open Puter` | Menu link to Puter |
|
||||
| `feedback-label` | `Send feedback` | Menu item that opens the feedback dialog |
|
||||
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* Signed out (a temporary account counts as signed out), it shows a Sign in
|
||||
* button. Signed in, it shows the user's profile picture (or initial), which
|
||||
* opens a menu with the account, links to manage it, see its usage and open
|
||||
* Puter, Add credits for an account with a plan, Send feedback, and Sign out.
|
||||
* On a page one subdomain under
|
||||
* Puter, Send feedback, and Sign out. On a page one subdomain under
|
||||
* the Puter origin, an Apps button opens Puter's app browser in a panel under
|
||||
* it, for the same user: signed out, or signed in as an account other than
|
||||
* the Puter session's, the browser shows only recommended apps. The browser
|
||||
@@ -14,8 +13,8 @@
|
||||
* Renders nothing inside the Puter desktop.
|
||||
*
|
||||
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
|
||||
* account-label, manage-account-label, usage-label, add-credits-label,
|
||||
* open-puter-label, feedback-label
|
||||
* account-label, manage-account-label, usage-label, open-puter-label,
|
||||
* feedback-label
|
||||
* Parts: apps-button, avatar, sign-in, menu, apps-panel
|
||||
* Events: signin (detail.user), signout
|
||||
*/
|
||||
@@ -38,7 +37,6 @@ const LABELS = {
|
||||
'account-label': 'Account',
|
||||
'manage-account-label': 'Manage account',
|
||||
'usage-label': 'Usage',
|
||||
'add-credits-label': 'Add credits',
|
||||
'open-puter-label': 'Open Puter',
|
||||
'feedback-label': 'Send feedback',
|
||||
};
|
||||
@@ -50,9 +48,6 @@ const MENU_LINKS = [
|
||||
{ className: 'open-puter', label: 'open-puter-label', path: '/' },
|
||||
];
|
||||
|
||||
// Sits under Usage, and shows only for an account with a plan.
|
||||
const ADD_CREDITS_ITEM = '<button type="button" class="menu-item add-credits" role="menuitem" hidden></button>';
|
||||
|
||||
// Gap between a panel and the viewport edge, and between its caret and corners.
|
||||
const EDGE = 8;
|
||||
const CARET_INSET = 20;
|
||||
@@ -518,7 +513,7 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
<span class="menu-email" hidden></span>
|
||||
</span>
|
||||
</div>
|
||||
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>${link.className === 'usage' ? ADD_CREDITS_ITEM : ''}`).join('')}
|
||||
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>`).join('')}
|
||||
<button type="button" class="menu-item feedback" role="menuitem"></button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" class="menu-item sign-out" role="menuitem"></button>
|
||||
@@ -553,7 +548,6 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className}`).addEventListener('click', () => this._closePanels());
|
||||
}
|
||||
this.$('.add-credits').addEventListener('click', () => this._addCredits());
|
||||
this.$('.feedback').addEventListener('click', () => this._sendFeedback());
|
||||
this.$('.sign-out').addEventListener('click', () => this._signOut());
|
||||
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
|
||||
@@ -587,7 +581,6 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
close.title = this._label('close-label');
|
||||
this.$('.sign-in').textContent = this._label('sign-in-label');
|
||||
this.$('.sign-out').textContent = this._label('sign-out-label');
|
||||
this.$('.add-credits').textContent = this._label('add-credits-label');
|
||||
this.$('.feedback').textContent = this._label('feedback-label');
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className} .menu-label`).textContent = this._label(link.label);
|
||||
@@ -605,19 +598,6 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-on credit is sold to accounts with a plan, which whoami reports where there are
|
||||
* plans. A seat's plan is its team's, and so is paying for its usage.
|
||||
*/
|
||||
_renderAddCredits () {
|
||||
// The SDK reads whoami into a new object when the same token is set again,
|
||||
// and an upgrade made on the page marks that one.
|
||||
const cached = globalThis.puter.whoami;
|
||||
const user = cached?.uuid && cached.uuid === this.#user?.uuid ? cached : this.#user;
|
||||
const isSeat = typeof user?.team?.uid === 'string' && user.team.uid !== '';
|
||||
this.$('.add-credits').hidden = user?.subscribed !== true || isSeat;
|
||||
}
|
||||
|
||||
_renderState () {
|
||||
const signedIn = this._signedIn();
|
||||
const username = this._username();
|
||||
@@ -634,7 +614,6 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
const email = typeof this.#user?.email === 'string' ? this.#user.email : '';
|
||||
this.$('.menu-email').textContent = email;
|
||||
this.$('.menu-email').hidden = !email;
|
||||
this._renderAddCredits();
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className}`).href = new URL(link.path, globalThis.puter.defaultGUIOrigin).href;
|
||||
}
|
||||
@@ -726,15 +705,13 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
*/
|
||||
_openMenu ({ fromKeyboard = false } = {}) {
|
||||
const menu = this.$('.menu');
|
||||
// An upgrade made on the page since the account loaded gives it a plan.
|
||||
this._renderAddCredits();
|
||||
this._showPanel(menu);
|
||||
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
|
||||
_moveMenuFocus (key) {
|
||||
const items = [...this.$$('.menu .menu-item:not([hidden])')];
|
||||
const items = [...this.$$('.menu .menu-item')];
|
||||
const current = items.indexOf(this.shadowRoot.activeElement);
|
||||
const last = items.length - 1;
|
||||
const next = {
|
||||
@@ -809,13 +786,6 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
);
|
||||
}
|
||||
|
||||
_addCredits () {
|
||||
// Focus goes back to the account button, where the dialog leaves it when it closes.
|
||||
this._closePanels({ returnFocus: true });
|
||||
// Called inside the click, so a tab it falls back to isn't blocked.
|
||||
void globalThis.puter.ui.showTopUp_();
|
||||
}
|
||||
|
||||
_sendFeedback () {
|
||||
this._closePanels();
|
||||
void globalThis.puter.ui.showFeedbackDialog();
|
||||
|
||||
@@ -26,7 +26,6 @@ const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
|
||||
},
|
||||
ui: {
|
||||
showFeedbackDialog: vi.fn(() => new Promise(() => {})),
|
||||
showTopUp_: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
auth: {
|
||||
getProfilePicture: vi.fn(async () => currentPicture),
|
||||
@@ -226,80 +225,6 @@ describe('<puter-user-menu>', () => {
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Add credits, under Usage, to an account with a plan', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
expect(part(el, '.add-credits').textContent).toBe('Add credits');
|
||||
expect(part(el, '.usage').nextElementSibling).toBe(part(el, '.add-credits'));
|
||||
|
||||
part(el, '.add-credits').click();
|
||||
expect(sdk.ui.showTopUp_).toHaveBeenCalledTimes(1);
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an account without a plan', { ...USER, subscribed: false }],
|
||||
['a server that reports no plans', USER],
|
||||
['a seat whose team pays for it', { ...USER, subscribed: true, team: { uid: 'team-1', name: 'Acme' } }],
|
||||
])('leaves Add credits out for %s, and out of the arrow keys', async (_label, user) => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 't', user }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
|
||||
part(el, '.usage').focus();
|
||||
part(el, '.usage').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, '.open-puter'));
|
||||
});
|
||||
|
||||
it('offers Add credits the next time the menu opens after an upgrade made on the page', async () => {
|
||||
const account = { ...USER, subscribed: false };
|
||||
vi.stubGlobal('puter', fakePuter({ token: 't', user: account }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
part(el, '.avatar').click();
|
||||
|
||||
// What the SDK does to its cached whoami when a framed upgrade reports a plan.
|
||||
account.subscribed = true;
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
});
|
||||
|
||||
it('offers Add credits after an upgrade marks a whoami read again for the same token', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: false } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
|
||||
// Setting the token again reads whoami into a new object, which the upgrade then marks.
|
||||
const reread = { ...USER, subscribed: false };
|
||||
sdk.signInAs('t', reread);
|
||||
sdk.whoami = reread;
|
||||
await settle();
|
||||
reread.subscribed = true;
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
|
||||
// Never another account's plan.
|
||||
part(el, '.avatar').click();
|
||||
sdk.whoami = { ...OTHER, subscribed: true };
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops offering Add credits once the sign-in changes to an account without a plan', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
expect(part(el, '.add-credits').hidden).toBe(false);
|
||||
sdk.signInAs('other-token', OTHER);
|
||||
await settle();
|
||||
expect(part(el, '.add-credits').hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('moves focus through the menu with the arrow, Home and End keys', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
|
||||
Reference in new issue
Block a user