`puter.perms.request()` already pooled a permission read and prompted only for what was missing. The raw `puter.ui.requestPermission()` did not, so every caller still on it re-asked the user on each launch — including `perms.requestAppData()`, whose own docs promise the opposite, and the driver-denial retry. - puter.js: `ui.requestPermission()` reads what is held before prompting and resolves true when the whole request is covered. Only in env=app and env=web, the environments that raise a prompt; elsewhere the method still answers false without asking anyone. A check that cannot be made — no token, an unreadable request shape, a failed read, or one that outlasts its timeout — falls through to the prompt rather than standing in for an answer. Public signature unchanged. - GUI: the request-permission popup asks the same question as the app, using the user-app token its own exchange already mints, and skips the dialog when the access is held. This is the one case the SDK cannot settle for itself: a signed-out site holds no token to check with. An origin the browser does not vouch for never reaches the check, since the exchange fails first. Both checks are time-boxed, because each one stands in front of something that is waiting: the popup's gates the dialog, so a stalled read would leave the prompt unshown and the opener pending, and the SDK's spends the browser's transient activation, which a slow read would cost the popup. Note that driver, service and feature scopes are implicitly granted to every app (backend/data/hardcoded-permissions.js), so requests for those now settle silently — the dialog was asking about access the app already had. Consent scopes (email, fs, apps, subdomains, app-data, app-root-dir) are unaffected and still prompt until granted. Fixes a bug this method already had on the way past: `pollDecision` read an undeclared `permission`, so every attempt threw a ReferenceError into its network-failure catch and the COOP-severed-opener recovery burned its full five-minute timeout before answering false. It polls `requested` now, and requires the whole list. Tests: the e2e suite drove its dialogs with an implicitly-held driver permission, so the fixture now asks for a driver nothing implies, fresh per page load, which also removes the cross-test grant carry-over the old revokes worked around. The reconciliation tests ask for the held scope plus an unheld one, since a fully-held request no longer reaches a dialog. Adds a backend contract test for check-permissions under an app-under-user actor, which is what the two new client paths rest on.
puter.js API test environment
Client-agnostic test suites for puter.js, run against a self-contained
in-memory Puter server — no external server, no stdout password scraping,
no .env. The same suite files execute on three platforms through thin
adapters:
| Runner | Platform | How the SDK runs |
|---|---|---|
runners/node.test.ts |
node.js | Built SDK bundle loaded into a fresh vm context per test (like src/init.cjs) |
runners/browser.test.ts |
headless Chromium (playwright) | Fixture page served same-origin on the API host loads /puter.js/v2 from the server itself |
runners/workerd.test.ts |
local workerd (Miniflare) | Suite bundle deployed as a real Puter worker via puter.workers.create, dispatched through the local worker proxy |
Running
Build the SDK bundle and worker preamble once (repeat after SDK changes):
npm run build:workerLib
Then, from the package root:
npm run test:puterjs # all three platforms
npm run test:puterjs:node
npm run test:puterjs:browser # needs `npx playwright install chromium` once
npm run test:puterjs:workerd
How it works
Each runner boots setupPuterTestEnv() (from src/backend/testUtil.ts) in
beforeAll: a fully in-memory backend (sqlite / dynalite / redis-mock /
fauxqs S3) listening on a real ephemeral port, with the production
extensions loaded and two deterministic users seeded:
admin— member of the admin group,testuser— a regular, non-privileged user (what suites run as).
The env manifest ({ origin, apiOrigin, users } with fixed passwords and
pre-minted session tokens) is JSON-serializable and crosses into whatever
runtime executes the tests. Root-only routes (e.g. POST /login) live on
origin; the SDK talks to apiOrigin (the api. subdomain host).
Adding tests
Tests are added once and run on all three platforms — never write a per-platform test here.
- Existing area (apps, auth, fs, kv): add a test to the matching
suites/<name>.suite.ts— one entry in the object, key is the test name, value gets the contextt. - New area (e.g. hosting): create
suites/hosting.suite.tsand register it insuites/index.ts(explicit list, no globbing — esbuild bundles exactly this list for the browser/workerd runners).
import { suite } from '../harness/types.ts';
export default suite('example', {
'does the thing': async (t) => {
await t.puter.fs.write(`/${t.env.users.user.username}/x.txt`, 'hi');
t.assert.ok(await t.puter.fs.stat(/* … */));
},
});
Rules that keep a suite runnable everywhere:
- Platform-agnostic only. No node/browser/workerd-specific imports —
a suite may use the SDK instance (
t.puter, authed as the regular user), globalfetch, andt.assert(ok/equal/deepEqual/rejects). - Admin or cross-user assertions go through plain
fetchwitht.env.users.admin.token(seeauth.suite.ts) — that works identically on every platform. - Unique resource names per test (file paths, kv keys): tests in a suite share one server and one user, so don't reuse names across tests.
- The runners in
runners/enumerate suites automatically — adding a suite requires no runner changes.
Iterate fast with npm run test:puterjs:node (boots in ~3s); run
npm run test:puterjs before pushing to cover browser and workerd too.