Compare commits

..
Author SHA1 Message Date
Nariman Jelveh eb8b153f47 Keep a new account's email check from closing without an answer
A signup that needs its email confirmed takes the auth card down and
waits on the confirmation window, whose ✕ closed it without ever
answering. In a sign-in popup reached from the account picker that left
an empty popup with nothing to pick. The check now stays until the code
is confirmed, like the phone and card checks and the boot-time email
check.
2026-11-06 14:18:37 -07:00
Nariman Jelveh 3bab9e8c6d Theme the signup bonus notice with the auth card
The notice kept the old signup window's fixed pastel colors, which stood
out as a light box on the card in dark mode. It now uses the dashboard's
success and danger colors, which follow the color scheme.
2026-11-06 14:17:53 -07:00
Nariman Jelveh 0c4bf8bdbc Show the provider buttons' hover in dark mode
The hover shade was the sidebar background, which in dark mode is one
step off the card's, so Google, Apple and the other sign-in buttons gave
no feedback under the pointer. Dark mode now uses the hover shade.
2026-11-06 14:17:19 -07:00
Nariman Jelveh fa7c1b0672 Keep the recovery-code field one size when it takes focus
On phones the generic dialog-input and phone input rules outranked the
recovery-code field's style until it was focused, so tapping it grew the
text from 16-17px to 20px and shifted the screen. The field's rule now
carries the same weight as theirs, like the code boxes beside it.
2026-11-06 14:16:31 -07:00
Nariman Jelveh 4422d87f95 Keep padded notices inside the auth page's column
On a phone-width page or in a sign-in popup, every direct child of the
auth body is stretched to the column's width. The overlay sits outside
.dashboard, so it isn't border-box, and the padded error line and bonus
notice came out wider than the fields and scrolled the page sideways.
2026-11-06 14:15:46 -07:00
Nariman Jelveh 63dad27fe7 Hand the session cookie back when a login is dismissed mid-request
The server sets the session cookie as soon as it accepts a login or a
second-factor code. Dropping an answer that arrived after the card closed
kept the page on its account while the cookie had moved to the other one,
so cookie-gated actions (password, 2FA) applied to that account and the
next load picked up its token. The cookie is now synced back to the
account the page is signed in as.
2026-11-06 14:09:09 -07:00
Nariman Jelveh cb44f17d2a Return to the login screen when signing in fails after a 2FA code
If storing the new session failed after a verified code, the error went
to the hidden login screen while the code screen stayed up, busy, with
its boxes locked. The card now goes back to the login screen first.
2026-11-06 13:38:18 -07:00
Nariman Jelveh 075c0dd59b Make the auth card's logo a link where it leads somewhere
On a cover page the Puter logo was an image with a click handler: no
keyboard access, no name for screen readers, no middle-click. It is now a
link named Puter. Everywhere else the logo is marked decorative, since the
title beside it names the screen.
2026-11-06 13:36:20 -07:00
Nariman Jelveh bd56c9fe73 Keep the current account when a login is dismissed mid-request
Closing the auth card while its login request was out still signed the
browser in to the other account and reloaded once the server answered,
though the caller had already been told the user dismissed it. A login
that answers after the card closed is now dropped.
2026-11-06 13:35:04 -07:00
Nariman Jelveh f7b69003e0 Return focus to the auth form when a request fails
A busy card parks focus on itself while its fields are locked. When the
server refused a signup, focus stayed there, so Enter did nothing and the
user had to tab back in. Ending busy now hands focus back to the control
that had it, once that control is enabled again.
2026-11-06 13:34:14 -07:00
jelveh 5279e3cc43 Span the screen with the auth bottom sheet on wider phones
The card's 400px cap outranked the dashboard sheet's full width, so on
phones between 401 and 600px wide the sheet left gaps on both sides.
2026-11-06 12:08:12 -07:00
jelveh 27a656af9e Reload after sign-in when only the URL's hash would change
On a dashboard tab (/#files) the redirect after a login or signup was
the same page with a hash, so location.replace only scrolled: the new
account was signed in but the page never reloaded, and the card stayed
up, busy, until closed.
2026-11-06 12:06:44 -07:00
jelveh c94f791e03 Point the Turnstile migration note at the new signup screen
UIWindowSignup no longer exists; the widget renders in UI/Auth/UIAuthSignup.js.
2026-11-06 11:53:12 -07:00
jelveh 334710a4f9 Keep focus in the auth card when its backdrop is pressed
A press on the dimmed backdrop (or a page's background) that didn't
dismiss the card moved focus to the page, so the next keys went to the
desktop's document shortcuts and Tab could leave the card.
2026-11-06 11:50:11 -07:00
jelveh e80199f61d Keep focus in the auth card while its form is busy
Submitting disabled the focused field, which dropped focus to the page,
so keys pressed while the request was in flight (Delete, Enter, letters)
reached the desktop's document shortcuts.
2026-11-06 11:47:34 -07:00
jelveh b2c392558a Keep focus where it is when a second sign-in prompt joins the card
A prompt that joins the open auth card focused the screen's first
field, so one arriving while the user typed their password sent the
rest of it into the username field. It now only brings focus back when
it has left the card.
2026-11-06 11:21:02 -07:00
jelveh 8775649fc3 Raise a failed session-cookie sync alert above the sign-in card
Signing in waits on this alert with the auth card still up. The card
sits above every ordinary window, so the alert opened underneath it and
the login spun forever. It now opens in the stay-on-top band.
2026-11-06 11:19:55 -07:00
jelveh cec7fe746c Give the auth card's Back and close bar a background once it scrolls
On a short screen the card's content scrolled under the bare Back and
close buttons, which then sat on top of it: the Back chevron covered the
edge of a provider button and took its taps. Once the showing screen is
scrolled, the bar turns opaque with a divider, as the footer has; at
rest nothing changes.
2026-11-06 11:12:20 -07:00
jelveh 4fe498edd5 Cover the page with a new account's checks when signup was a page
Signup as a page (a sign-in popup, /action/signup) closes its card for
the email, phone and card checks; they opened as floating windows over
an empty backdrop instead of covering the page as they did before.
2026-11-06 11:08:49 -07:00
jelveh 67b2e8074e Show the signup screen's errors above the provider buttons
The status line lived inside the email form, which hides behind the
provider buttons (or for good, when only provider signups are open), so
an error the screen opened with, like a refused provider signup, was
never shown.
2026-11-06 11:07:49 -07:00
jelveh 01e4ff9c12 Move login, signup, account picker, password recovery and 2FA to dashboard-style dialogs
The sign-in surfaces were separate UIWindows that opened over each other
(login, then a recovery window over it, then a 2FA window over that). They
are now screens of one dashboard-style card built on mountDashboardOverlay,
the same shell, fields, buttons and status line as the dashboard's account
dialogs. Screens replace each other in place, with Back, and keep what was
typed.

- Page presentation for cover pages and sign-in popups: an opaque page with
  a centered card, filling the screen on phones and in popups. Dialog
  presentation elsewhere: dimmed backdrop, centered card, bottom sheet on
  phones.
- Each call site keeps whether it could be dismissed, its promise contract,
  OIDC gating in popups, Turnstile, the honeypot, bonus codes, the
  post-signup verification gates, reauth auth_id, and redirect handling.
- Keys typed in the card no longer reach the desktop's document shortcuts.
- A second sign-in prompt joins the open flow instead of stacking a card.
- Server messages render as text; prefilled values are encoded.
2026-11-06 10:45:25 -07:00
19 changed files with 89 additions and 184 deletions

No files matched your search

+2 -6
View File
@@ -176,10 +176,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
# Missing when the PR leg failed; `coverage (pr)` already reports that.
- name: Download PR coverage
id: pr-coverage
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-pr
@@ -189,7 +186,6 @@ jobs:
# base run) — then report without the comparison column.
- name: Download base coverage
id: base-coverage
if: steps.pr-coverage.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
@@ -197,7 +193,7 @@ jobs:
path: coverage-base
- name: Report coverage on PR
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
if: steps.base-coverage.outcome == 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
@@ -206,7 +202,7 @@ jobs:
json-summary-compare-path: coverage-base/coverage-summary.json
- name: Report coverage on PR (no base comparison)
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
if: steps.base-coverage.outcome != 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
@@ -144,22 +144,11 @@ const createInline = async (
return String(response.body.subId);
};
/**
* The post each label made, in order, once all have arrived. Matched by label
* since earlier tests' subscriptions stay live and post to the same sink.
*/
const delivered = async (...labels: string[]): Promise<SinkPost[]> => {
await vi.waitFor(
() =>
expect(sinkPosts.map((post) => post.label)).toEqual(
expect.arrayContaining(labels),
),
{ timeout: 30_000, interval: 50 },
);
return labels.map((label) =>
sinkPosts.find((post) => post.label === label)!,
);
};
const delivered = (count = 1): Promise<void> =>
vi.waitFor(() => expect(sinkPosts.length).toBeGreaterThanOrEqual(count), {
timeout: 30_000,
interval: 50,
});
beforeAll(async () => {
env = await setupPuterTestEnv({
@@ -266,11 +255,14 @@ describe('an inline handler in a user scope, end to end', () => {
await env.server.services.fs.touch(holder.id, {
path: `${holder.anchor}/note.txt`,
});
const [post] = await delivered('inline');
await delivered();
expect(post.path).toBe(`${holder.anchor}/note.txt`);
expect(sinkPosts[0]).toMatchObject({
label: 'inline',
path: `${holder.anchor}/note.txt`,
});
const { actor } = await env.server.services.auth.authenticate(
post.token!,
sinkPosts[0].token!,
);
expect(actor!.user?.id).toBe(holder.id);
expect(actor!.effectiveApp?.uid).toBe(appUid);
@@ -305,14 +297,14 @@ describe('an inline handler in a user scope, end to end', () => {
await env.server.services.fs.touch(holder.id, {
path: `${holder.anchor}/shared-${i}.txt`,
});
// The first test's 'inline' handler also posts for holders[0].
const posts = await delivered('first', 'second');
for (const [i, post] of posts.entries()) {
const { actor } = await env.server.services.auth.authenticate(
post.token!,
await delivered(2);
const runAs = new Set<number>();
for (const post of sinkPosts)
runAs.add(
(await env.server.services.auth.authenticate(post.token!))
.actor!.user!.id,
);
expect(actor!.user!.id).toBe(holders[i].id);
}
expect(runAs).toEqual(new Set(holders.map((h) => h.id)));
create.mockRestore();
},
);
@@ -78,10 +78,7 @@ describe('LocalWorkerService shutdown disposal', () => {
disposals[0].mockImplementationOnce(async () => {
await held;
});
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
// The test server keeps arming timers from real callbacks while fakes
// are installed, so only what shutdown arms synchronously is checked.
const arm = vi.spyOn(globalThis, 'setTimeout');
vi.useFakeTimers();
try {
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
@@ -89,13 +86,12 @@ describe('LocalWorkerService shutdown disposal', () => {
finished = true;
},
);
expect(arm.mock.calls.map(([, ms]) => ms)).toEqual([5_000]);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
expect(vi.getTimerCount()).toBe(0);
} finally {
arm.mockRestore();
release();
vi.useRealTimers();
}
@@ -2860,12 +2860,8 @@ describe('OpenAIRealtimeRelayService relay', () => {
),
).toHaveLength(64),
);
// Holds are reserved and usage recorded one at a time, which a loaded
// runner takes past vi.waitFor's default second.
const settled = (check: () => Promise<void> | void) =>
vi.waitFor(check, { timeout: 5_000 });
// The pool grew one item per commit, with nothing spare at the cap.
await settled(async () =>
await vi.waitFor(async () =>
expect(
await server.services.metering.getOutstandingHolds(actor),
).toBe(64 * 10_896_000),
@@ -2894,7 +2890,7 @@ describe('OpenAIRealtimeRelayService relay', () => {
for (let batch = 0; batch < 8; batch++) {
for (let i = Math.max(1, batch * 8); i < (batch + 1) * 8; i++)
complete(i);
await settled(() =>
await vi.waitFor(() =>
expect(
recorded.mock.calls
.flatMap(([, rows]) => rows)
@@ -2910,13 +2906,13 @@ describe('OpenAIRealtimeRelayService relay', () => {
.filter((row) => row.usageType.includes('transcription_'))
.reduce((sum, row) => sum + row.costOverride!, 0),
).toBe(640);
await settled(async () =>
await vi.waitFor(async () =>
expect(
await server.services.metering.getOutstandingHolds(actor),
).toBe(2 * 10_896_000),
);
c.ws.close();
await settled(async () =>
await vi.waitFor(async () =>
expect(
await server.services.metering.getOutstandingHolds(actor),
).toBe(0),
+4 -9
View File
@@ -1542,22 +1542,18 @@ describe('AppStore detailed stats', () => {
let db;
const APP_UID = `app-stats-${Date.now()}`;
// The windows come from the clock, so it is pinned: mid-week and
// mid-month, and minutes clear of midnight in any time zone.
const NOW = new Date('2026-06-17T12:07:30Z');
const nowSeconds = Math.floor(NOW.getTime() / 1000);
const nowSeconds = Math.floor(Date.now() / 1000);
beforeAll(async () => {
server = await setupTestServer();
appStore = server.stores.app;
db = server.clients.db;
await clearAppCache(server.clients.redis);
vi.useFakeTimers({ toFake: ['Date'], now: NOW });
// Two users in one hour. Windows end at now, exclusive.
// Two users; the repeat shares a timestamp so it is always same-hour.
for (const [userId, offset] of [
[1, 60],
[1, 60],
[1, 0],
[1, 0],
[2, 120],
]) {
await db.write(
@@ -1568,7 +1564,6 @@ describe('AppStore detailed stats', () => {
});
afterAll(async () => {
vi.useRealTimers();
await server?.shutdown();
});
-3
View File
@@ -154,9 +154,6 @@ export const setupTestServer = async (
redis: { useMock: true },
// Suites run system jobs themselves, not on the poll timer.
jobs: { enabled: false },
// The health loop's timed `SELECT 1` would land in tests that
// count queries.
server_health: { disabled_checks: ['database-liveness'] },
s3: { localConfig: { inMemory: true } },
no_default_user: true,
no_devwatch: true,
+3 -10
View File
@@ -37,9 +37,6 @@ const repoRoot = path.resolve(backendDir, '../..');
const isPgmockMode =
(process.env.PUTER_TEST_DB_ENGINE ?? '').toLowerCase() === 'postgres';
const pgmockTimeoutMs = 600_000;
// Loaded CI runners stall timers well past the 5 s / 10 s defaults; a real
// hang still fails, just later.
const ciTimeouts = { testTimeout: 20_000, hookTimeout: 60_000 };
// Tests that always boot pgmock regardless of PUTER_TEST_DB_ENGINE. Too slow
// for CI; run locally or via `npm run test:backend:postgres`.
@@ -106,11 +103,9 @@ export const lowerDecoratorsPlugin = {
loader: 'ts',
target: 'es2024',
sourcefile: id,
sourcemap: 'external',
sourcemap: 'inline',
});
// Without the map, coverage and stack traces report esbuild's
// output lines instead of the source's.
return { code: result.code, map: result.map };
return { code: result.code, map: null };
},
};
@@ -150,9 +145,7 @@ export default defineConfig(({ mode }) => ({
hookTimeout: pgmockTimeoutMs,
fileParallelism: false,
}
: isCi
? ciTimeouts
: {}),
: {}),
coverage: {
provider: 'v8',
reporter: isCi
+2
View File
@@ -194,6 +194,7 @@ export function pushSignupScreen (flow, options = {}) {
// them, and an identity provider's refusal shows here too.
h += '<div class="dashboard-form-status auth-screen-status" role="status" aria-live="polite"></div>';
h += '<div class="signup-bonus-notice" hidden></div>';
h += `<p class="signup-oidc-only-notice" hidden>${i18n('signup_oidc_only')}</p>`;
h += '<div class="auth-providers oidc-providers-wrapper" hidden>';
h += '<div class="auth-provider-list"></div>';
h += '</div>';
@@ -268,6 +269,7 @@ export function pushSignupScreen (flow, options = {}) {
$form.prop('hidden', true);
$screen.find('.signup-with-email-wrapper').prop('hidden', true);
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
$screen.find('.signup-oidc-only-notice').prop('hidden', false);
};
// Turnstile renders into the form once its script has loaded.
+4 -1
View File
@@ -71,13 +71,15 @@ describe('signup when only OIDC signups are open', () => {
await open();
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(false);
expect($('.signup-form').prop('hidden')).toBe(true);
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(true);
$('.signup-with-email-btn').trigger('click');
expect($('.signup-form').prop('hidden')).toBe(false);
});
it('drops the email form', async () => {
it('drops the email form and says why', async () => {
providers({ providers: ['google'], signupOidcOnly: true });
await open();
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(false);
expect($('.signup-form').prop('hidden')).toBe(true);
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(true);
});
@@ -92,6 +94,7 @@ describe('signup when only OIDC signups are open', () => {
await vi.waitFor(() => expect(calls).toHaveLength(1));
calls[0].error({ responseText: JSON.stringify({ code: 'signup_oidc_only', message: 'closed' }) });
await tick();
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(false);
expect($('.signup-form').prop('hidden')).toBe(true);
});
-3
View File
@@ -201,9 +201,6 @@ export default function openAuthFlow ({ page = false, dismissable = ! page, clos
const { $overlay, $dialog, close: closeOverlay, isClosed } = mountDashboardOverlay({
html: h,
// A page already fills the screen. Resizing it under iOS's own pan to
// the focused field makes the form jump up and down as the keyboard opens.
fitKeyboard: ! as_page,
canDismiss: (via) => dismissable
&& (via === 'close' || (! busy && (via !== 'backdrop' || closeOnBackdrop))),
onDismiss: () => {
+2 -4
View File
@@ -72,11 +72,9 @@ export function bindBackdropDismiss ($overlay, isBackdrop, dismiss) {
* overlay, after it has closed. Not called for `close()`.
* @param {(via: 'escape'|'close'|'backdrop') => boolean} [opts.canDismiss]
* Consulted before each dismissal; returning false keeps the overlay up.
* @param {boolean} [opts.fitKeyboard] Shrink to the part of the screen an
* on-screen keyboard leaves visible. Defaults to true.
* @returns {{ $overlay: jQuery, $dialog: jQuery, close: () => void, isClosed: () => boolean }}
*/
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss, canDismiss, fitKeyboard = true }) {
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss, canDismiss }) {
const $root = $container && $container.length ? $container : $('body');
const ns = `.dashboard-overlay-${++overlay_seq}`;
const el_previous_focus = document.activeElement;
@@ -89,7 +87,7 @@ export default function mountDashboardOverlay ({ html, $container, returnFocusTo
// iOS lays its keyboard over the page rather than resizing it, which
// would leave a bottom sheet's fields and buttons underneath. Pin the
// overlay to the part of the screen that is still visible.
const viewport = fitKeyboard ? window.visualViewport : null;
const viewport = window.visualViewport;
const fitToViewport = () => {
const covered = window.innerHeight - viewport.height - viewport.offsetTop;
const pinned = covered > 1 || viewport.offsetTop > 1;
@@ -150,13 +150,6 @@ describe('mountDashboardOverlay', () => {
close();
expect(window.visualViewport.removeEventListener).toHaveBeenCalledWith('resize', listeners.resize);
});
it('leaves its size alone under a keyboard when told not to fit it', () => {
window.visualViewport = { height: window.innerHeight, offsetTop: 0, addEventListener: vi.fn(), removeEventListener: vi.fn() };
const { close } = mountDashboardOverlay({ html: card, fitKeyboard: false });
expect(window.visualViewport.addEventListener).not.toHaveBeenCalled();
close();
});
});
describe('bindBackdropDismiss', () => {
+8 -3
View File
@@ -10692,11 +10692,9 @@ body.dashboard-mode .notifications-close-all {
z-index: 99999998;
}
/* As a page there is nothing behind the card to go back to. It covers the
screen at once rather than fading in over whatever was there. */
/* As a page there is nothing behind the card to go back to. */
.dashboard-dialog-overlay.auth-dialog-page {
background: var(--dashboard-sidebar-background);
transition: none;
}
.dashboard-dialog.auth-dialog {
@@ -11073,6 +11071,13 @@ body.dashboard-mode .notifications-close-all {
color: var(--dashboard-danger-text);
}
.auth-dialog .signup-oidc-only-notice {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--dashboard-text-secondary);
}
.auth-dialog .oidc-providers-wrapper {
margin-top: 0;
}
+7 -60
View File
@@ -1925,60 +1925,6 @@ span.header-sort-icon img {
background: transparent;
}
/* Dark mode: same surfaces as the dashboard's dark cards. The translucent
background is also set in the backdrop-filter @supports block below. */
@media (prefers-color-scheme: dark) {
.context-menu {
color: #d4d4d4;
background-color: rgb(38 38 38 / 85%);
border-color: #3d3d3d;
box-shadow: 0px 3px 20px #00000066;
}
.context-menu .context-menu-divider>hr {
border-top-color: #ffffff26;
}
.context-menu .has-open-context-menu-submenu {
background-color: #3a3a3a;
}
/* `color: initial` resolves to black, unreadable on the dark surface. */
.context-menu .context-menu-item-active-blurred {
background-color: #404650;
color: inherit;
}
.context-menu .context-menu-item-disabled, .context-menu .context-menu-item-disabled:hover {
color: inherit;
}
/* The resting chevron is black; the active one is already white. */
.context-menu .submenu-arrow:not(.submenu-arrow-active) {
filter: invert(1);
}
/* Full-color icons (file types, app icons) keep their colors; only
monochrome dark glyphs are inverted. */
.context-menu .context-menu-item .ctx-item-icon-monochrome {
filter: invert(1);
}
.context-menu.context-menu-sheet {
background-color: rgba(38, 38, 38, 0.96) !important;
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.5) !important;
}
.context-menu.context-menu-sheet .context-menu-item-active:not(.context-menu-divider) {
background-color: rgba(10, 132, 255, 0.24) !important;
color: #d4d4d4 !important;
}
.context-menu.context-menu-sheet .context-menu-divider > hr {
border-top-color: rgba(235, 235, 245, 0.18);
}
}
.draggable-count-badge {
background-color: red;
border: 2px solid white;
@@ -2314,6 +2260,13 @@ label {
color: #611a15;
}
.signup-oidc-only-notice {
text-align: center;
margin: 0 0 10px;
font-size: 14px;
color: #5f6368;
}
/* -- Logo strip above the login / signup forms -- */
.auth-logo-header {
display: flex;
@@ -3979,12 +3932,6 @@ fieldset[name=number-code] {
backdrop-filter: blur(3px);
}
@media (prefers-color-scheme: dark) {
.context-menu {
background-color: rgb(38 38 38 / 92%);
}
}
.popover:not(.device-phone .popover) {
background-color: rgb(238, 243, 248);
backdrop-filter: blur(10px);
+1
View File
@@ -996,6 +996,7 @@ const en = {
sign_in_with_provider: 'Sign in with %%',
sign_up_with_provider: 'Sign up with %%',
sign_up_with_email: 'Sign up using email',
signup_oidc_only: 'New accounts can only be created with one of the options below.',
popup_opener_uses_puter: '%strong% is powered by Puter for its AI and cloud features.\nUse your Puter account to continue.',
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
+14 -20
View File
@@ -1874,13 +1874,9 @@ window.initgui = async function (options) {
//--------------------------------------------------------------------------------------
// Desktop background (early)
// Set before action=login/signup so the signed-in dialog has the desktop behind it.
// Skipped for the full-page form, which it would only flash behind before covering.
// Set before action=login/signup so OIDC error redirects show the background behind the form.
// -------------------------------------------------------------------------------------
const authActionIsPage =
action === 'password-recovery' ||
(!window.is_auth() && (action === 'login' || action === 'signup'));
if (!window.is_fullpage_mode && !window.embedded_in_popup && !authActionIsPage) {
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
window.refresh_desktop_background();
}
@@ -2430,23 +2426,12 @@ window.initgui = async function (options) {
window.update_sites_cache();
}
}
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
// real account even on a first visit — never a temp user. So does a share
// link: a share only ever reaches a real account, so a temporary one could
// never see what it points at.
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
const is_share_link = window.url_query_params.has('shared');
const needsSignIn =
!window.is_auth() &&
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link);
// -------------------------------------------------------------------------------------
// Desktop Background
// If we're in fullpage/emebedded/Auth Popup mode, we don't want to load the custom background
// because it's not visible anyway and it's a waste of bandwidth. Same behind the sign-in page.
// because it's not visible anyway and it's a waste of bandwidth
// -------------------------------------------------------------------------------------
const showsSignInPage = needsSignIn && window.logged_in_users.length === 0;
if (!window.is_fullpage_mode && !window.embedded_in_popup && !showsSignInPage) {
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
window.refresh_desktop_background();
}
// -------------------------------------------------------------------------------------
@@ -2457,7 +2442,16 @@ window.initgui = async function (options) {
// then failed to authenticate.
if (!window.is_auth()) reveal_anonymous_markup(true);
if (needsSignIn) {
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
// real account even on a first visit — never a temp user. So does a share
// link: a share only ever reaches a real account, so a temporary one could
// never see what it points at.
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
const is_share_link = window.url_query_params.has('shared');
if (
!window.is_auth() &&
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link)
) {
// `npm start --server=<remote>` serves this GUI locally while pointing
// `gui_origin` at a remote Puter. There is nothing here to log into:
// `/login` accepts only its own origin, because it answers with a full
@@ -29,14 +29,15 @@ const resolveBundle = (): string => {
);
};
// In coverage mode every vm context is handed the first context's
// `__coverage__`: istanbul reuses a file entry whose hash matches, so all
// contexts count into one object instead of each holding a ~6 MB copy.
let nodeCoverage: IstanbulCoverage | undefined;
// In coverage mode every vm context is retained so its `__coverage__`
// counters can be merged after the run (each test loads a fresh SDK).
const coverageContexts: Array<Record<string, unknown>> = [];
/** Coverage counters from every SDK context created so far. */
export const collectNodeCoverage = (): IstanbulCoverage | undefined =>
nodeCoverage;
export const collectNodeCoverage = (): Array<IstanbulCoverage | undefined> =>
coverageContexts.map(
(context) => context.__coverage__ as IstanbulCoverage | undefined,
);
// Compile the ~MB bundle once; isolation comes from a fresh context per
// call, not a fresh parse. The filename ties stack traces and V8 coverage
@@ -75,12 +76,9 @@ export const loadNodePuter = (env: EnvManifest, token: string): PuterSDK => {
// The SDK's nodejs branch only shims localStorage when it's absent;
// clear any inherited one so instances never share auth state.
delete context.localStorage;
if (nodeCoverage) context.__coverage__ = nodeCoverage;
sdkScript().runInContext(vm.createContext(context));
if (coverageEnabled()) {
nodeCoverage ??= context.__coverage__ as IstanbulCoverage | undefined;
}
if (coverageEnabled()) coverageContexts.push(context);
const puter = context.puter as PuterSDK;
puter.setAuthToken(token);
+1 -1
View File
@@ -35,7 +35,7 @@ describe('puter.js API suites (node)', () => {
afterAll(async () => {
if (coverageEnabled()) {
await writeCoverageShard('node', [collectNodeCoverage()]);
await writeCoverageShard('node', collectNodeCoverage());
}
await env?.shutdown();
});
@@ -1,4 +1,3 @@
import { createCoverageMap } from 'istanbul-lib-coverage';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
setupPuterTestEnv,
@@ -7,7 +6,11 @@ import {
import type { IConfig } from '@heyputer/backend/types.ts';
import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts';
import { loadPuterJsTestOptions } from '../harness/capabilities.ts';
import { coverageEnabled, writeCoverageShard } from '../harness/coverage.ts';
import {
coverageEnabled,
writeCoverageShard,
type IstanbulCoverage,
} from '../harness/coverage.ts';
import { listTests, skipReason, type RunTestArgs } from '../harness/executor.ts';
import { loadNodePuter } from '../harness/nodeSdkLoader.ts';
import type { EnvManifest, RunTestResult } from '../harness/types.ts';
@@ -17,9 +20,8 @@ const WORKER_NAME = 'puterjs-suites';
const options = loadPuterJsTestOptions();
// Counters ride along on every /run response (the workerd isolate can
// recycle mid-run). Merged as they arrive, since keeping each ~6 MB
// snapshot until the end runs the fork out of heap.
const workerdCoverage = createCoverageMap({});
// recycle mid-run); merged into one shard after the run.
const workerdCoverage: IstanbulCoverage[] = [];
// The shared puter.js suites running inside local workerd. The suite
// bundle is deployed through the real workers pipeline (SDK
@@ -59,7 +61,7 @@ describe('puter.js API suites (workerd)', () => {
afterAll(async () => {
if (coverageEnabled()) {
await writeCoverageShard('workerd', [workerdCoverage.toJSON()]);
await writeCoverageShard('workerd', workerdCoverage);
}
await env?.shutdown();
});
@@ -87,7 +89,7 @@ describe('puter.js API suites (workerd)', () => {
}
const result = JSON.parse(text) as RunTestResult;
if (result.coverage) {
workerdCoverage.merge(result.coverage);
workerdCoverage.push(result.coverage);
}
expect(result.error ?? '').toBe('');
expect(result.ok).toBe(true);