diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml
index 917d11577..0edba6bf6 100644
--- a/.github/workflows/backend-tests.yaml
+++ b/.github/workflows/backend-tests.yaml
@@ -1,10 +1,19 @@
name: Backend Tests
+# Runs on backend and extension changes. Tests are co-located with the
+# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
+# changes match the same globs. puter.js SDK changes are covered by
+# puterjs-tests.yaml instead.
on:
pull_request:
types: [opened, synchronize, reopened]
- paths-ignore:
- - 'src/docs/**'
+ paths:
+ - 'src/backend/**'
+ - 'extensions/**'
+ - 'tools/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/backend-tests.yaml'
permissions:
contents: read
diff --git a/.github/workflows/puterjs-tests.yaml b/.github/workflows/puterjs-tests.yaml
new file mode 100644
index 000000000..d005665f9
--- /dev/null
+++ b/.github/workflows/puterjs-tests.yaml
@@ -0,0 +1,53 @@
+name: Puter.js API Tests
+
+# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
+# so test changes are covered by the same glob — plus the pieces the
+# runners depend on: the worker preamble, the in-memory test env in
+# testUtil, and the backend vitest config the API-tests config extends.
+# Backend and extension changes run backend-tests.yaml instead.
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'src/puter-js/**'
+ - 'src/worker/**'
+ - 'src/backend/testUtil.ts'
+ - 'src/backend/vitest.config.ts'
+ - 'tools/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/puterjs-tests.yaml'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ # The runners exercise the built artifacts: the SDK bundle
+ # (src/puter-js/dist) for node + browser, and the worker preamble
+ # (src/worker/dist) for workerd.
+ - name: Build puter.js SDK and worker preamble
+ run: npm run build:workerLib
+
+ - name: Install Playwright chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Run puter.js API tests (node, browser, workerd)
+ run: npm run test:puterjs
+ env:
+ CI: 'true'
diff --git a/package.json b/package.json
index d4494109a..671d9962b 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,10 @@
"scripts": {
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
+ "test:puterjs": "npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
+ "test:puterjs:node": "npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
+ "test:puterjs:browser": "npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
+ "test:puterjs:workerd": "npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run setupExtensions && npm run build:ts",
diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts
index 8c1f2514f..9f34004d6 100644
--- a/src/backend/drivers/workers/WorkerDriver.ts
+++ b/src/backend/drivers/workers/WorkerDriver.ts
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { EventMetadata } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
@@ -47,10 +47,17 @@ let preambleError = false;
let preambleLineCount = 0;
let preambleVersion: string | null = null;
try {
- const preamblePath = path.join(
- __dirname,
- '../../../../../src/worker/dist/workerPreamble.js',
- );
+ // Five levels up from `dist/src/backend/drivers/workers` (compiled
+ // runtime); four when running from `src/backend/drivers/workers`
+ // directly (vitest transforms the TS sources in place).
+ const preamblePath = [
+ path.join(
+ __dirname,
+ '../../../../../src/worker/dist/workerPreamble.js',
+ ),
+ path.join(__dirname, '../../../../src/worker/dist/workerPreamble.js'),
+ ].find((candidate) => existsSync(candidate));
+ if (!preamblePath) throw new Error('workerPreamble.js not found');
console.log('reading: ' + preamblePath);
preamble = readFileSync(preamblePath, 'utf-8');
preambleLineCount = preamble.split('\n').length - 1;
diff --git a/src/backend/server.ts b/src/backend/server.ts
index acad0bf58..727b9712d 100644
--- a/src/backend/server.ts
+++ b/src/backend/server.ts
@@ -1088,7 +1088,31 @@ export class PuterServer {
const entryPath = `${extDir}/${entry.name}`;
if (entry.isFile()) {
- if (/\.(js|mjs|cjs)$/.test(entry.name)) {
+ const name = entry.name;
+ let shouldImport: boolean;
+ if (this.#config.import_ts_extensions) {
+ // Extensions ship as compiled .js at runtime, but
+ // transform-capable runtimes (the test harness)
+ // import the .ts sources directly. Skip tests and
+ // declarations, and skip built .js siblings of a
+ // .ts source so a previously-built tree doesn't
+ // double-register.
+ if (name.endsWith('.ts')) {
+ shouldImport =
+ !name.endsWith('.test.ts') &&
+ !name.endsWith('.d.ts');
+ } else {
+ shouldImport =
+ /\.(js|mjs|cjs)$/.test(name) &&
+ !/\.test\.(js|mjs|cjs)$/.test(name) &&
+ !existsSync(
+ entryPath.replace(/\.(js|mjs|cjs)$/, '.ts'),
+ );
+ }
+ } else {
+ shouldImport = /\.(js|mjs|cjs)$/.test(name);
+ }
+ if (shouldImport) {
console.log(`Importing extension file ${entryPath}`);
await import(pathToFileURL(entryPath).href);
}
@@ -1151,72 +1175,82 @@ export class PuterServer {
}
if (!noHttpServer) {
- this.#server = httpServer.listen(this.#config.port, async () => {
- const cfg = this.#config;
- const liveUrl =
- cfg.origin ??
- `${cfg.protocol ?? 'http'}://${cfg.domain ?? 'localhost'}:${this.#config.port}`;
- console.log(
- '\n************************************************************',
- );
- console.log(`* Puter is now live at: ${liveUrl}`);
- console.log(
- '************************************************************\n',
- );
-
- await this.#fireOnServerStart();
- console.log('PuterServer has fully booted.');
-
- // CLI: `--server` (optionally `--puter-backend=`)
- // runs the AuthMe flow against a remote Puter (default
- // puter.com), then opens the local GUI already logged in and
- // pointed at that backend. Restores the v1 WebServerService
- // `--server` behavior; works in any env. When set, it takes
- // over browser launch so we don't also open a plain tab.
- const { values: cliArgs } = parseArgs({
- args: process.argv.slice(2),
- options: {
- server: { type: 'boolean' },
- 'puter-backend': { type: 'string' },
- },
- strict: false,
+ // Await 'listening' (and full boot below) so callers can rely on
+ // the server being reachable once start() resolves — test
+ // harnesses connect real clients immediately after.
+ this.#server = httpServer.listen(this.#config.port);
+ await new Promise((resolve, reject) => {
+ const onError = (err: Error) => reject(err);
+ httpServer.once('error', onError);
+ httpServer.once('listening', () => {
+ // Detach so post-boot 'error' events aren't swallowed
+ // by a no-op reject on this settled promise.
+ httpServer.removeListener('error', onError);
+ resolve();
});
-
- if (cliArgs.server) {
- try {
- // tools/auth_gui.js is not compiled into dist/, so
- // resolve it from the package root (cwd, per the
- // `start` script) rather than relative to this module.
- const authGuiUrl = pathToFileURL(
- path.resolve(process.cwd(), 'tools/auth_gui.js'),
- ).href;
- const authGui = (await import(authGuiUrl)).default;
- await authGui(
- cliArgs['puter-backend'] as string | undefined,
- );
- } catch (e) {
- console.log(
- '[server] could not start AuthMe browser flow:',
- (e as Error).message,
- );
- }
- } else if (
- this.#config.env === 'dev' &&
- !cfg.no_browser_launch
- ) {
- // Auto-launch the browser on dev boot (matches v1
- // WebServerService). Opt out via `no_browser_launch: true`.
- try {
- const openModule = await import('open');
- await openModule.default(liveUrl);
- } catch (e) {
- console.log(
- '[server] could not auto-open browser:',
- (e as Error).message,
- );
- }
- }
});
+
+ const cfg = this.#config;
+ const liveUrl =
+ cfg.origin ??
+ `${cfg.protocol ?? 'http'}://${cfg.domain ?? 'localhost'}:${this.#config.port}`;
+ console.log(
+ '\n************************************************************',
+ );
+ console.log(`* Puter is now live at: ${liveUrl}`);
+ console.log(
+ '************************************************************\n',
+ );
+
+ await this.#fireOnServerStart();
+ console.log('PuterServer has fully booted.');
+
+ // CLI: `--server` (optionally `--puter-backend=`)
+ // runs the AuthMe flow against a remote Puter (default
+ // puter.com), then opens the local GUI already logged in and
+ // pointed at that backend. Restores the v1 WebServerService
+ // `--server` behavior; works in any env. When set, it takes
+ // over browser launch so we don't also open a plain tab.
+ const { values: cliArgs } = parseArgs({
+ args: process.argv.slice(2),
+ options: {
+ server: { type: 'boolean' },
+ 'puter-backend': { type: 'string' },
+ },
+ strict: false,
+ });
+
+ if (cliArgs.server) {
+ try {
+ // tools/auth_gui.js is not compiled into dist/, so
+ // resolve it from the package root (cwd, per the
+ // `start` script) rather than relative to this module.
+ const authGuiUrl = pathToFileURL(
+ path.resolve(process.cwd(), 'tools/auth_gui.js'),
+ ).href;
+ const authGui = (await import(authGuiUrl)).default;
+ await authGui(
+ cliArgs['puter-backend'] as string | undefined,
+ );
+ } catch (e) {
+ console.log(
+ '[server] could not start AuthMe browser flow:',
+ (e as Error).message,
+ );
+ }
+ } else if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
+ // Auto-launch the browser on dev boot (matches v1
+ // WebServerService). Opt out via `no_browser_launch: true`.
+ try {
+ const openModule = await import('open');
+ await openModule.default(liveUrl);
+ } catch (e) {
+ console.log(
+ '[server] could not auto-open browser:',
+ (e as Error).message,
+ );
+ }
+ }
} else {
this.#server = {
close: (cb: (error?: Error) => void | undefined) => {
@@ -1255,47 +1289,51 @@ export class PuterServer {
}
}
+ #prepareShutdownHooksRan = false;
+
+ /**
+ * Run every layer's `onServerPrepareShutdown` exactly once, whichever
+ * of `prepareShutdown()` / `shutdown()` gets there first.
+ */
+ async #runPrepareShutdownHooks() {
+ if (this.#prepareShutdownHooksRan) return;
+ this.#prepareShutdownHooksRan = true;
+ for (const client of Object.values(this.clients) as WithLifecycle[]) {
+ if (client.onServerPrepareShutdown) {
+ await client.onServerPrepareShutdown();
+ }
+ }
+ for (const store of Object.values(this.stores) as WithLifecycle[]) {
+ if (store.onServerPrepareShutdown) {
+ await store.onServerPrepareShutdown();
+ }
+ }
+ for (const service of Object.values(this.services) as WithLifecycle[]) {
+ if (service.onServerPrepareShutdown) {
+ await service.onServerPrepareShutdown();
+ }
+ }
+ for (const controller of Object.values(
+ this.controllers,
+ ) as WithLifecycle[]) {
+ if (controller.onServerPrepareShutdown) {
+ await controller.onServerPrepareShutdown();
+ }
+ }
+ for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
+ if (driver.onServerPrepareShutdown) {
+ await driver.onServerPrepareShutdown();
+ }
+ }
+ }
+
async prepareShutdown() {
if (this.#server) {
this.#server.close(async () => {
console.log(
'PuterServer has stopped accepting new connections',
);
- for (const client of Object.values(
- this.clients,
- ) as WithLifecycle[]) {
- if (client.onServerPrepareShutdown) {
- await client.onServerPrepareShutdown();
- }
- }
- for (const store of Object.values(
- this.stores,
- ) as WithLifecycle[]) {
- if (store.onServerPrepareShutdown) {
- await store.onServerPrepareShutdown();
- }
- }
- for (const service of Object.values(
- this.services,
- ) as WithLifecycle[]) {
- if (service.onServerPrepareShutdown) {
- await service.onServerPrepareShutdown();
- }
- }
- for (const controller of Object.values(
- this.controllers,
- ) as WithLifecycle[]) {
- if (controller.onServerPrepareShutdown) {
- await controller.onServerPrepareShutdown();
- }
- }
- for (const driver of Object.values(
- this.drivers,
- ) as WithLifecycle[]) {
- if (driver.onServerPrepareShutdown) {
- await driver.onServerPrepareShutdown();
- }
- }
+ await this.#runPrepareShutdownHooks();
});
}
}
@@ -1303,7 +1341,18 @@ export class PuterServer {
async shutdown() {
if (this.#server) {
console.log('PuterServer is shutting down');
+ // Prepare hooks come first: SocketService's hook closes
+ // socket.io, disconnecting upgraded websocket connections that
+ // `closeAllConnections()` does not cover — without this,
+ // `close()` waits forever on any connected socket.io client.
+ await this.#runPrepareShutdownHooks();
+ // Stop accepting new connections, then sever live ones; the
+ // close callback fires once the listener is fully released.
+ const closed = new Promise((resolve) => {
+ this.#server!.close(() => resolve());
+ });
this.#server.closeAllConnections();
+ await closed;
for (const client of Object.values(
this.clients,
) as WithLifecycle[]) {
diff --git a/src/backend/services/selfhosted/DefaultUserService.ts b/src/backend/services/selfhosted/DefaultUserService.ts
index 7730e7f47..0b62d1687 100644
--- a/src/backend/services/selfhosted/DefaultUserService.ts
+++ b/src/backend/services/selfhosted/DefaultUserService.ts
@@ -28,7 +28,7 @@ import { LOCAL_UNLIMITED_USER } from '../../data/subPolicies/localUnlimitedUserP
import { UNLIMITED_SUBSCRIPTION } from '../metering/consts.js';
const USERNAME = 'admin';
-const ADMIN_GROUP_UID = 'ca342a5e-b13d-4dee-9048-58b11a57cc55';
+export const ADMIN_GROUP_UID = 'ca342a5e-b13d-4dee-9048-58b11a57cc55';
const ADMIN_STORAGE_BYTES = 10 * 1024 * 1024 * 1024;
/**
diff --git a/src/backend/testUtil.test.ts b/src/backend/testUtil.test.ts
new file mode 100644
index 000000000..a543077df
--- /dev/null
+++ b/src/backend/testUtil.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { setupPuterTestEnv } from './testUtil.ts';
+
+// Exercises the client-facing test environment: a real HTTP listener on an
+// ephemeral port, deterministic seeded users, and both auth paths clients
+// use (pre-minted session token, and a real password login).
+describe('setupPuterTestEnv', () => {
+ it('boots a reachable server with working credentials', async () => {
+ const env = await setupPuterTestEnv();
+ try {
+ const who = await fetch(`${env.apiOrigin}/whoami`, {
+ headers: {
+ Authorization: `Bearer ${env.users.user.token}`,
+ Origin: env.apiOrigin,
+ },
+ });
+ expect(who.status).toBe(200);
+ const whoBody = (await who.json()) as { username: string };
+ expect(whoBody.username).toBe(env.users.user.username);
+
+ // /login is a root-only route — it lives on the root origin,
+ // not the api subdomain.
+ const login = await fetch(`${env.origin}/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: env.origin,
+ },
+ body: JSON.stringify({
+ username: env.users.admin.username,
+ password: env.users.admin.password,
+ }),
+ });
+ expect(login.status).toBe(200);
+ const loginBody = (await login.json()) as { token?: string };
+ expect(loginBody.token).toBeTruthy();
+ } finally {
+ await env.shutdown();
+ }
+ }, 120_000);
+});
diff --git a/src/backend/testUtil.ts b/src/backend/testUtil.ts
index 496ac1d24..ad76d1ef3 100644
--- a/src/backend/testUtil.ts
+++ b/src/backend/testUtil.ts
@@ -1,3 +1,8 @@
+import bcrypt from 'bcrypt';
+import { createServer } from 'node:net';
+import type { AddressInfo } from 'node:net';
+import { fileURLToPath } from 'node:url';
+import { v4 as uuidv4 } from 'uuid';
import { deepMerge } from '../../tools/lib/configMigration.mjs';
import { PuterServer } from './server';
import { IConfig } from './types';
@@ -7,6 +12,8 @@ import {
type PostgresPool,
} from './clients/database/PostgresDatabaseClient';
import type { PoolConfig } from 'pg';
+import { ADMIN_GROUP_UID } from './services/selfhosted/DefaultUserService';
+import { generateDefaultFsentries } from './util/userProvisioning';
export const POSTGRES_TEST_MIGRATIONS_PATH =
'src/backend/clients/database/migrations/postgres';
@@ -88,15 +95,50 @@ const testDatabaseDefault = (): IConfig['database'] => {
return { engine: 'sqlite', inMemory: true };
};
+export type SetupTestServerOptions = {
+ /**
+ * Listen on a real HTTP port so external clients (puter.js runners,
+ * workerd, browsers) can connect. Default: in-process only, no listener.
+ */
+ listen?: boolean;
+};
+
+/**
+ * Grab a free port by binding to 0 and releasing it. Done up-front (rather
+ * than letting the server listen on 0) so the port is known while building
+ * config — `origin` / `api_base_url` consumers like LocalWorkerService read
+ * it at construction time.
+ */
+export const allocateEphemeralPort = (): Promise =>
+ new Promise((resolve, reject) => {
+ const probe = createServer();
+ probe.unref();
+ probe.on('error', reject);
+ probe.listen(0, '127.0.0.1', () => {
+ const { port } = probe.address() as AddressInfo;
+ probe.close(() => resolve(port));
+ });
+ });
+
+// The JSON module's payload lives on `.default` — merging the namespace
+// itself would bury every value under a stray `default` key.
+const loadDefaultConfig = async (): Promise => {
+ const { default: defaultConfig } = await import(
+ '../../config.default.json',
+ {
+ with: {
+ type: 'json',
+ },
+ }
+ );
+ return defaultConfig as unknown as IConfig;
+};
+
export const setupTestServer = async (
configOverrides?: IConfig,
+ options?: SetupTestServerOptions,
): Promise => {
- // read default config json
- const defaultConfig = await import('../../config.default.json', {
- with: {
- type: 'json',
- },
- });
+ const defaultConfig = await loadDefaultConfig();
// merge default config with overrides and test defaults
const config = deepMerge(
deepMerge(defaultConfig, {
@@ -108,10 +150,23 @@ export const setupTestServer = async (
s3: { localConfig: { inMemory: true } },
no_default_user: true,
no_devwatch: true,
+ no_browser_launch: true,
+ import_ts_extensions: true,
}),
configOverrides ?? {},
) as IConfig;
+ if (options?.listen) {
+ if (!config.port) config.port = await allocateEphemeralPort();
+ // Derive from `domain` so the advertised origins match the host
+ // the subdomain gates expect (`api.` for API routes).
+ const host = config.domain ?? '127.0.0.1';
+ config.origin ??= `http://${host}:${config.port}`;
+ config.api_base_url ??= config.domain
+ ? `http://api.${config.domain}:${config.port}`
+ : config.origin;
+ }
+
let pgMockClient: PgMockPostgresHarness | undefined;
if (usesPgMockPostgres(config)) {
const database = config.database;
@@ -141,10 +196,147 @@ export const setupTestServer = async (
}
try {
- await server.start(true);
+ await server.start(!options?.listen);
} catch (e) {
pgMockClient?.destroy();
throw e;
}
return server;
};
+
+export type TestUserCredentials = {
+ username: string;
+ password: string;
+ token: string;
+};
+
+/**
+ * Seed a user with a known password directly through the stores (same steps
+ * as DefaultUserService's admin bootstrap: bcrypt-hashed password, home
+ * directory tree, optional admin-group membership) and mint a session token
+ * the same way `POST /login` does.
+ */
+export const createTestUser = async (
+ server: PuterServer,
+ opts: { username: string; password: string; admin?: boolean },
+): Promise => {
+ const passwordHash = await bcrypt.hash(opts.password, 8);
+ const created = await server.stores.user.create({
+ username: opts.username,
+ uuid: uuidv4(),
+ password: passwordHash,
+ email: null,
+ requires_email_confirmation: false,
+ });
+
+ // Base driver permissions (kv, notifications, …) are system grants on
+ // the default user group — membership is what a verified signup gets.
+ const { default_user_group } = await loadDefaultConfig();
+ if (default_user_group) {
+ await server.stores.group.addUsers(default_user_group, [opts.username]);
+ }
+ if (opts.admin) {
+ await server.stores.group.addUsers(ADMIN_GROUP_UID, [opts.username]);
+ }
+
+ await generateDefaultFsentries(
+ server.clients.db,
+ server.stores.user,
+ created,
+ );
+ const user = (await server.stores.user.getById(created.id)) ?? created;
+
+ const { token } = await server.services.auth.createSessionToken(user, {
+ user_agent: 'puter-test-env',
+ });
+
+ return { username: opts.username, password: opts.password, token };
+};
+
+export type PuterTestEnv = {
+ /**
+ * Root origin (`http://puter.localhost:`) — GUI and root-only
+ * routes like `POST /login` live here.
+ */
+ origin: string;
+ /**
+ * API origin (`http://api.puter.localhost:`) — what puter.js
+ * clients use as their APIOrigin. Routes gated on the `api` subdomain
+ * (e.g. `/whoami`) only match this host.
+ */
+ apiOrigin: string;
+ /** Seeded accounts: an admin and a regular (non-privileged) user. */
+ users: {
+ admin: TestUserCredentials;
+ user: TestUserCredentials;
+ };
+ server: PuterServer;
+ shutdown: () => Promise;
+};
+
+export const TEST_ADMIN_CREDENTIALS = {
+ username: 'admin',
+ password: 'puter-test-admin-password',
+};
+export const TEST_USER_CREDENTIALS = {
+ username: 'testuser',
+ password: 'puter-test-user-password',
+};
+
+/**
+ * Boot an in-memory Puter server on a real ephemeral port with deterministic
+ * credentials, for client test runners (puter.js on node, browsers, workerd).
+ * Clients can authenticate with the pre-minted tokens or via a real
+ * `POST /login` using the fixed passwords — no stdout scraping.
+ */
+export const setupPuterTestEnv = async (
+ configOverrides?: IConfig,
+): Promise => {
+ const port = await allocateEphemeralPort();
+ // Real hostnames rather than 127.0.0.1: API routes are gated on the
+ // `api` subdomain, and `*.localhost` resolves to loopback on modern
+ // platforms (and in browsers).
+ const domain = 'puter.localhost';
+ const origin = `http://${domain}:${port}`;
+ const apiOrigin = `http://api.${domain}:${port}`;
+
+ // Unlike unit-test servers (extensions: []), the client env loads the
+ // real extensions — clients depend on endpoints that live there
+ // (e.g. /whoami). Resolved from this module so cwd doesn't matter.
+ const extensionsDir = fileURLToPath(
+ new URL('../../extensions', import.meta.url),
+ );
+
+ const server = await setupTestServer(
+ deepMerge(
+ {
+ port,
+ domain,
+ origin,
+ api_base_url: apiOrigin,
+ extensions: [extensionsDir],
+ },
+ configOverrides ?? {},
+ ) as IConfig,
+ { listen: true },
+ );
+
+ try {
+ const admin = await createTestUser(server, {
+ ...TEST_ADMIN_CREDENTIALS,
+ admin: true,
+ });
+ const user = await createTestUser(server, TEST_USER_CREDENTIALS);
+
+ return {
+ origin,
+ apiOrigin,
+ users: { admin, user },
+ server,
+ shutdown: () => server.shutdown(),
+ };
+ } catch (e) {
+ await server.shutdown().catch(() => {});
+ throw e;
+ }
+};
diff --git a/src/backend/types.ts b/src/backend/types.ts
index cd6f536df..865641c09 100644
--- a/src/backend/types.ts
+++ b/src/backend/types.ts
@@ -532,6 +532,12 @@ interface IConfigOptional {
* banner that DefaultUserService prints. Intended for tests.
*/
no_default_user: boolean;
+ /**
+ * Import `.ts` extension sources instead of built `.js`. Only for
+ * transform-capable runtimes (the test harness sets this); plain node
+ * cannot execute the TypeScript sources.
+ */
+ import_ts_extensions?: boolean;
/** Optional dev-time frontend watcher overrides. */
devwatch: IDevWatcherConfig;
diff --git a/src/backend/vitest.config.ts b/src/backend/vitest.config.ts
index 8b7801caf..82faa9e35 100644
--- a/src/backend/vitest.config.ts
+++ b/src/backend/vitest.config.ts
@@ -42,7 +42,9 @@ const pgmockTimeoutMs = 600_000;
// crash with "SyntaxError: Invalid or unexpected token". Pre-transform
// `.ts`/`.mts` source through esbuild — which DOES lower stage-3
// decorators — locked to `es2024` to match `tsconfig.json`'s target.
-const lowerDecoratorsPlugin = {
+// Exported for other vitest configs that boot backend code (e.g. the
+// puter.js API test runners).
+export const lowerDecoratorsPlugin = {
name: 'puter:lower-decorators',
enforce: 'pre' as const,
async transform(code: string, id: string) {
diff --git a/src/puter-js/tests/api/README.md b/src/puter-js/tests/api/README.md
new file mode 100644
index 000000000..7e42e704b
--- /dev/null
+++ b/src/puter-js/tests/api/README.md
@@ -0,0 +1,83 @@
+# 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):
+
+```sh
+npm run build:workerLib
+```
+
+Then, from the package root:
+
+```sh
+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.
+
+1. **Existing area** (apps, auth, fs, kv): add a test to the matching
+ `suites/.suite.ts` — one entry in the object, key is the test
+ name, value gets the context `t`.
+2. **New area** (e.g. hosting): create `suites/hosting.suite.ts` and
+ register it in `suites/index.ts` (explicit list, no globbing — esbuild
+ bundles exactly this list for the browser/workerd runners).
+
+```ts
+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), global `fetch`, and `t.assert` (`ok/equal/deepEqual/rejects`).
+- **Admin or cross-user assertions** go through plain `fetch` with
+ `t.env.users.admin.token` (see `auth.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.
diff --git a/src/puter-js/tests/api/harness/assert.ts b/src/puter-js/tests/api/harness/assert.ts
new file mode 100644
index 000000000..3490d98e2
--- /dev/null
+++ b/src/puter-js/tests/api/harness/assert.ts
@@ -0,0 +1,74 @@
+/**
+ * Minimal assertions for the shared puter.js suites. Suites run inside
+ * browsers and workerd where no test framework exists, so they can't use
+ * vitest's `expect` — this is the lowest common denominator.
+ */
+export class AssertionError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'AssertionError';
+ }
+}
+
+export const show = (value: unknown): string => {
+ try {
+ return JSON.stringify(value) ?? String(value);
+ } catch {
+ return String(value);
+ }
+};
+
+// Sort object keys recursively so deepEqual doesn't depend on key order.
+const canonical = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(canonical);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .sort(([a], [b]) => (a < b ? -1 : 1))
+ .map(([k, v]) => [k, canonical(v)]),
+ );
+ }
+ return value;
+};
+
+export const assert = {
+ ok(value: unknown, message?: string): void {
+ if (!value) {
+ throw new AssertionError(
+ message ?? `expected truthy value, got ${show(value)}`,
+ );
+ }
+ },
+
+ equal(actual: unknown, expected: unknown, message?: string): void {
+ if (actual !== expected) {
+ throw new AssertionError(
+ message ??
+ `expected ${show(expected)}, got ${show(actual)}`,
+ );
+ }
+ },
+
+ deepEqual(actual: unknown, expected: unknown, message?: string): void {
+ if (show(canonical(actual)) !== show(canonical(expected))) {
+ throw new AssertionError(
+ message ??
+ `expected ${show(expected)}, got ${show(actual)}`,
+ );
+ }
+ },
+
+ async rejects(
+ fn: () => Promise,
+ message?: string,
+ ): Promise {
+ try {
+ await fn();
+ } catch (e) {
+ return e;
+ }
+ throw new AssertionError(message ?? 'expected promise to reject');
+ },
+};
+
+export type Assert = typeof assert;
diff --git a/src/puter-js/tests/api/harness/browserEntry.ts b/src/puter-js/tests/api/harness/browserEntry.ts
new file mode 100644
index 000000000..9b9e4979f
--- /dev/null
+++ b/src/puter-js/tests/api/harness/browserEntry.ts
@@ -0,0 +1,25 @@
+import { runTest, type RunTestArgs } from './executor.ts';
+import type { PuterSDK, RunTestResult } from './types.ts';
+
+/**
+ * Browser-side entry, esbuild-bundled into the fixture page by the browser
+ * runner. The page loads the real SDK bundle from the test server
+ * (`/puter.js/v2`) with `window.PUTER_API_ORIGIN` pre-set, so `window.puter`
+ * is already pointed at the right origin; each call authenticates it as the
+ * regular test user and runs one named suite test.
+ */
+declare global {
+ interface Window {
+ puter: PuterSDK;
+ __runSuiteTest__: (args: RunTestArgs) => Promise;
+ }
+}
+
+window.__runSuiteTest__ = async (args) => {
+ const puter = window.puter;
+ if (!puter) {
+ return { ok: false, error: 'window.puter is not loaded' };
+ }
+ puter.setAuthToken(args.env.users.user.token);
+ return runTest(args, puter);
+};
diff --git a/src/puter-js/tests/api/harness/bundleHarnessEntry.ts b/src/puter-js/tests/api/harness/bundleHarnessEntry.ts
new file mode 100644
index 000000000..edf7dee47
--- /dev/null
+++ b/src/puter-js/tests/api/harness/bundleHarnessEntry.ts
@@ -0,0 +1,20 @@
+import { fileURLToPath } from 'node:url';
+import { build } from 'esbuild';
+
+/**
+ * Bundle a harness entry (which pulls in the executor and all suites) into
+ * a single IIFE script runnable in a foreign runtime — a browser fixture
+ * page or a worker. Node-side only; never import this from an entry that
+ * itself gets bundled.
+ */
+export const bundleHarnessEntry = async (entryUrl: URL): Promise => {
+ const result = await build({
+ entryPoints: [fileURLToPath(entryUrl)],
+ bundle: true,
+ write: false,
+ format: 'iife',
+ platform: 'browser',
+ target: 'es2022',
+ });
+ return result.outputFiles[0].text;
+};
diff --git a/src/puter-js/tests/api/harness/executor.ts b/src/puter-js/tests/api/harness/executor.ts
new file mode 100644
index 000000000..a2a66d61b
--- /dev/null
+++ b/src/puter-js/tests/api/harness/executor.ts
@@ -0,0 +1,75 @@
+import { suites } from '../suites/index.ts';
+import { assert, show } from './assert.ts';
+import type {
+ EnvManifest,
+ Platform,
+ PuterSDK,
+ RunTestResult,
+} from './types.ts';
+
+export type RunTestArgs = {
+ suiteName: string;
+ testName: string;
+ env: EnvManifest;
+ platform: Platform;
+};
+
+/**
+ * Run one named suite test against an already-configured SDK instance.
+ * This is the piece every platform adapter funnels into: imported directly
+ * by the node runner, bundled into the fixture page for browsers and into
+ * the worker script for workerd. The platform-specific part — obtaining a
+ * `puter` pointed at `env.apiOrigin` and authed as the regular user — stays
+ * in the adapters.
+ */
+export const runTest = async (
+ args: RunTestArgs,
+ puter: PuterSDK,
+): Promise => {
+ const suite = suites.find((s) => s.name === args.suiteName);
+ const test = suite?.tests[args.testName];
+ if (!test) {
+ return {
+ ok: false,
+ error: `unknown test "${args.suiteName} > ${args.testName}"`,
+ };
+ }
+
+ try {
+ await test({
+ puter,
+ env: args.env,
+ assert,
+ platform: args.platform,
+ });
+ return { ok: true };
+ } catch (e) {
+ const err = e as Error | undefined;
+ // SDK errors often carry structured context (e.g. `failedItems` on
+ // partial batch failures) — surface own enumerable props too.
+ let details = '';
+ if (e && typeof e === 'object') {
+ const extras = Object.fromEntries(
+ Object.entries(e as Record).filter(
+ ([, v]) => typeof v !== 'function',
+ ),
+ );
+ if (Object.keys(extras).length > 0) {
+ details = `\ndetails: ${show(extras)}`;
+ }
+ }
+ return {
+ ok: false,
+ error: (err?.stack ?? err?.message ?? show(e)) + details,
+ };
+ }
+};
+
+/** Enumerate all tests — adapters use this to emit one `it()` per test. */
+export const listTests = (): Array<{ suiteName: string; testName: string }> =>
+ suites.flatMap((s) =>
+ Object.keys(s.tests).map((testName) => ({
+ suiteName: s.name,
+ testName,
+ })),
+ );
diff --git a/src/puter-js/tests/api/harness/nodeSdkLoader.ts b/src/puter-js/tests/api/harness/nodeSdkLoader.ts
new file mode 100644
index 000000000..961675bb4
--- /dev/null
+++ b/src/puter-js/tests/api/harness/nodeSdkLoader.ts
@@ -0,0 +1,70 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import vm from 'node:vm';
+import type { EnvManifest, PuterSDK } from './types.ts';
+
+// Prefer the npm-published name, fall back to webpack's raw output so a
+// plain `npm run build` (no prepublish rename) is enough for local runs.
+// PUTER_SDK_BUNDLE=dev forces the unminified sourcemapped bundle for
+// readable stack traces when debugging suite failures.
+const BUNDLE_CANDIDATES = ['../../../dist/puter.cjs', '../../../dist/puter.js'];
+const DEV_BUNDLE = '../../../dist/puter.dev.js';
+
+const resolveBundle = (): string => {
+ const candidates =
+ process.env.PUTER_SDK_BUNDLE === 'dev'
+ ? [DEV_BUNDLE]
+ : BUNDLE_CANDIDATES;
+ for (const candidate of candidates) {
+ const abs = fileURLToPath(new URL(candidate, import.meta.url));
+ if (existsSync(abs)) return abs;
+ }
+ throw new Error(
+ 'puter.js bundle not found — run `npm run build` in src/puter-js first',
+ );
+};
+
+// 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
+// entries back to the bundle on disk.
+let cachedScript: vm.Script | null = null;
+const sdkScript = (): vm.Script => {
+ if (!cachedScript) {
+ const bundlePath = resolveBundle();
+ cachedScript = new vm.Script(readFileSync(bundlePath, 'utf8'), {
+ // file:// form so stack traces point at the bundle on disk
+ // rather than an anonymous eval.
+ filename: pathToFileURL(bundlePath).href,
+ });
+ }
+ return cachedScript;
+};
+
+/**
+ * Load the built puter.js bundle into a fresh vm context — the same
+ * technique as `src/init.cjs`, but parameterized on the test env and
+ * isolated per call so each invocation gets an independent SDK instance
+ * (own localStorage shim, own auth state).
+ */
+export const loadNodePuter = (env: EnvManifest, token: string): PuterSDK => {
+ const context: Record = {};
+ for (const name of Object.getOwnPropertyNames(globalThis)) {
+ try {
+ context[name] = (globalThis as Record)[name];
+ } catch {
+ // some globals throw on access; skip them
+ }
+ }
+ context.globalThis = context;
+ context.PUTER_API_ORIGIN = env.apiOrigin;
+ context.PUTER_ORIGIN = env.origin;
+ // 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;
+
+ sdkScript().runInContext(vm.createContext(context));
+
+ const puter = context.puter as PuterSDK;
+ puter.setAuthToken(token);
+ return puter;
+};
diff --git a/src/puter-js/tests/api/harness/types.ts b/src/puter-js/tests/api/harness/types.ts
new file mode 100644
index 000000000..287030d5a
--- /dev/null
+++ b/src/puter-js/tests/api/harness/types.ts
@@ -0,0 +1,59 @@
+import type { Puter } from '../../../types/puter.d.ts';
+import type { Assert } from './assert.ts';
+
+export type PuterSDK = Puter;
+
+export type TestUserCredentials = {
+ username: string;
+ password: string;
+ token: string;
+};
+
+/**
+ * The platform-agnostic description of a running test server. Produced by
+ * `setupPuterTestEnv` (backend testUtil) on the node side and handed to
+ * whichever runtime executes the tests. Must stay JSON-serializable — it
+ * crosses into browsers and workerd.
+ */
+export type EnvManifest = {
+ /** Root origin — GUI and root-only routes like `POST /login`. */
+ origin: string;
+ /** API origin — what puter.js uses as its APIOrigin. */
+ apiOrigin: string;
+ users: {
+ admin: TestUserCredentials;
+ user: TestUserCredentials;
+ };
+};
+
+export type Platform = 'node' | 'browser' | 'workerd';
+
+/**
+ * What every suite test receives. `puter` is authenticated as the regular
+ * (non-privileged) user; admin-side assertions go through plain `fetch`
+ * with `env.users.admin.token` so suites stay runnable on every platform.
+ */
+export type TestContext = {
+ puter: PuterSDK;
+ env: EnvManifest;
+ assert: Assert;
+ platform: Platform;
+};
+
+export type SuiteTest = (t: TestContext) => void | Promise;
+
+/** Outcome of a single test run, serializable across runtime boundaries. */
+export type RunTestResult = {
+ ok: boolean;
+ error?: string;
+};
+
+export type Suite = {
+ name: string;
+ tests: Record;
+};
+
+export const suite = (
+ name: string,
+ tests: Record,
+): Suite => ({ name, tests });
diff --git a/src/puter-js/tests/api/harness/workerdEntry.ts b/src/puter-js/tests/api/harness/workerdEntry.ts
new file mode 100644
index 000000000..48c07fc19
--- /dev/null
+++ b/src/puter-js/tests/api/harness/workerdEntry.ts
@@ -0,0 +1,34 @@
+import { runTest, type RunTestArgs } from './executor.ts';
+import type { PuterSDK, RunTestResult } from './types.ts';
+
+/**
+ * Worker-side entry, esbuild-bundled and deployed as a real Puter worker by
+ * the workerd runner. The preamble's router provides `event.user.puter` —
+ * a per-request SDK instance authenticated from the `puter-auth` header and
+ * pointed at the `puter_endpoint` binding (the test server) — so tests run
+ * against the exact SDK setup production workers get.
+ */
+type RouterEvent = {
+ request: Request;
+ user?: { puter: PuterSDK };
+};
+
+declare const router: {
+ custom(
+ method: string,
+ route: string,
+ handler: (event: RouterEvent) => unknown,
+ ): void;
+};
+
+router.custom('POST', '/run', async (event: RouterEvent): Promise => {
+ const args = (await event.request.json()) as RunTestArgs;
+ const puter = event.user?.puter;
+ if (!puter) {
+ return {
+ ok: false,
+ error: 'no per-request puter — was the puter-auth header sent?',
+ };
+ }
+ return await runTest(args, puter);
+});
diff --git a/src/puter-js/tests/api/runners/browser.test.ts b/src/puter-js/tests/api/runners/browser.test.ts
new file mode 100644
index 000000000..f2d2d35ea
--- /dev/null
+++ b/src/puter-js/tests/api/runners/browser.test.ts
@@ -0,0 +1,97 @@
+import { chromium, type Browser, type Page } from '@playwright/test';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ setupPuterTestEnv,
+ type PuterTestEnv,
+} from '@heyputer/backend/testUtil.ts';
+import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts';
+import { listTests, type RunTestArgs } from '../harness/executor.ts';
+import type { EnvManifest, RunTestResult } from '../harness/types.ts';
+
+const FIXTURE_PATH = '/__puterjs_suites__/fixture.html';
+
+// The shared puter.js suites running in a real (headless)
+// browser via playwright. The fixture page is fulfilled via route
+// interception *on the API origin itself*, so the SDK runs same-origin
+// exactly like a page served by the server; the SDK bundle comes from the
+// server's own /puter.js/v2 route.
+describe('puter.js API suites (browser)', () => {
+ let env: PuterTestEnv;
+ let manifest: EnvManifest;
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv();
+ manifest = {
+ origin: env.origin,
+ apiOrigin: env.apiOrigin,
+ users: env.users,
+ };
+
+ const bundle = await bundleHarnessEntry(
+ new URL('../harness/browserEntry.ts', import.meta.url),
+ );
+ const fixtureHtml = `
+
+
+
+puter.js suite fixture
+
+
+
+
+
+`;
+
+ browser = await chromium.launch({
+ args: [
+ // Chromium gates cross-origin requests to loopback targets
+ // behind Local/Private Network Access checks, which are
+ // auto-denied in headless — and the signed-upload flow PUTs
+ // straight to the in-memory S3 on 127.0.0.1.
+ '--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessChecks,BlockInsecurePrivateNetworkRequests',
+ ],
+ });
+ page = await browser.newPage();
+ // Surface network-level failures (DNS, CORS, blocked requests) —
+ // inside the page they all collapse into opaque "network error"s.
+ page.on('requestfailed', (req) => {
+ console.log(
+ `[browser] request failed: ${req.method()} ${req.url()} — ${req.failure()?.errorText}`,
+ );
+ });
+ await page.route(`**${FIXTURE_PATH}`, (route) =>
+ route.fulfill({ contentType: 'text/html', body: fixtureHtml }),
+ );
+ await page.goto(`${env.apiOrigin}${FIXTURE_PATH}`);
+ await page.waitForFunction(
+ () =>
+ Boolean(
+ (window as { __runSuiteTest__?: unknown })
+ .__runSuiteTest__,
+ ),
+ null,
+ { timeout: 30_000 },
+ );
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ await env?.shutdown();
+ });
+
+ for (const { suiteName, testName } of listTests()) {
+ it(`${suiteName} > ${testName}`, async () => {
+ const result = await page.evaluate(
+ (args) => window.__runSuiteTest__(args),
+ { suiteName, testName, env: manifest, platform: 'browser' },
+ );
+ expect(result.error ?? '').toBe('');
+ expect(result.ok).toBe(true);
+ });
+ }
+});
diff --git a/src/puter-js/tests/api/runners/node.test.ts b/src/puter-js/tests/api/runners/node.test.ts
new file mode 100644
index 000000000..498f951c1
--- /dev/null
+++ b/src/puter-js/tests/api/runners/node.test.ts
@@ -0,0 +1,41 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ setupPuterTestEnv,
+ type PuterTestEnv,
+} from '@heyputer/backend/testUtil.ts';
+import { listTests, runTest } from '../harness/executor.ts';
+import { loadNodePuter } from '../harness/nodeSdkLoader.ts';
+import type { EnvManifest } from '../harness/types.ts';
+
+// The shared puter.js suites running under node.js: the built SDK bundle
+// is loaded into a vm context (like `src/init.cjs`) against an in-memory
+// server on a real port.
+describe('puter.js API suites (node)', () => {
+ let env: PuterTestEnv;
+ let manifest: EnvManifest;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv();
+ manifest = {
+ origin: env.origin,
+ apiOrigin: env.apiOrigin,
+ users: env.users,
+ };
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ for (const { suiteName, testName } of listTests()) {
+ it(`${suiteName} > ${testName}`, async () => {
+ const puter = loadNodePuter(manifest, manifest.users.user.token);
+ const result = await runTest(
+ { suiteName, testName, env: manifest, platform: 'node' },
+ puter,
+ );
+ expect(result.error ?? '').toBe('');
+ expect(result.ok).toBe(true);
+ });
+ }
+});
diff --git a/src/puter-js/tests/api/runners/workerd.test.ts b/src/puter-js/tests/api/runners/workerd.test.ts
new file mode 100644
index 000000000..1a01e9dd3
--- /dev/null
+++ b/src/puter-js/tests/api/runners/workerd.test.ts
@@ -0,0 +1,79 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ setupPuterTestEnv,
+ type PuterTestEnv,
+} from '@heyputer/backend/testUtil.ts';
+import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts';
+import { listTests, type RunTestArgs } from '../harness/executor.ts';
+import { loadNodePuter } from '../harness/nodeSdkLoader.ts';
+import type { EnvManifest, RunTestResult } from '../harness/types.ts';
+
+const WORKER_NAME = 'puterjs-suites';
+
+// The shared puter.js suites running inside local workerd. The suite
+// bundle is deployed through the real workers pipeline (SDK
+// `workers.create` → WorkerDriver → LocalWorkerService), and each test is
+// dispatched over HTTP through the local worker proxy at
+// `.workers.puter.localhost`, exactly like a production Puter worker.
+describe('puter.js API suites (workerd)', () => {
+ let env: PuterTestEnv;
+ let manifest: EnvManifest;
+ let workerUrl: string;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv({
+ // Anything truthy (without ACCOUNTID) routes worker deploys to
+ // the local workerd instead of the remote workers backend.
+ workers: { localServer: 'true' },
+ } as never);
+ manifest = {
+ origin: env.origin,
+ apiOrigin: env.apiOrigin,
+ users: env.users,
+ };
+
+ const bundle = await bundleHarnessEntry(
+ new URL('../harness/workerdEntry.ts', import.meta.url),
+ );
+
+ // Deploy through the SDK as the regular user — same flow as a real
+ // `puter.workers.create` from an app.
+ const puter = loadNodePuter(manifest, manifest.users.user.token);
+ const sourcePath = `/${manifest.users.user.username}/suite-worker.js`;
+ await puter.fs.write(sourcePath, bundle);
+ await puter.workers.create(WORKER_NAME, sourcePath);
+
+ const port = new URL(env.apiOrigin).port;
+ workerUrl = `http://${WORKER_NAME}.workers.puter.localhost:${port}/run`;
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ for (const { suiteName, testName } of listTests()) {
+ it(`${suiteName} > ${testName}`, async () => {
+ const args: RunTestArgs = {
+ suiteName,
+ testName,
+ env: manifest,
+ platform: 'workerd',
+ };
+ const res = await fetch(workerUrl, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'puter-auth': manifest.users.user.token,
+ },
+ body: JSON.stringify(args),
+ });
+ const text = await res.text();
+ if (res.status !== 200) {
+ throw new Error(`worker returned ${res.status}: ${text}`);
+ }
+ const result = JSON.parse(text) as RunTestResult;
+ expect(result.error ?? '').toBe('');
+ expect(result.ok).toBe(true);
+ });
+ }
+});
diff --git a/src/puter-js/tests/api/suites/apps.suite.ts b/src/puter-js/tests/api/suites/apps.suite.ts
new file mode 100644
index 000000000..32a60b9a1
--- /dev/null
+++ b/src/puter-js/tests/api/suites/apps.suite.ts
@@ -0,0 +1,48 @@
+import { suite } from '../harness/types.ts';
+
+export default suite('apps', {
+ 'create registers an app retrievable by name': async (t) => {
+ const app = await t.puter.apps.create(
+ 'apps-suite-create',
+ 'https://example.com/create',
+ );
+ t.assert.equal(app.name, 'apps-suite-create');
+ const fetched = await t.puter.apps.get('apps-suite-create');
+ t.assert.equal(fetched.index_url, 'https://example.com/create');
+ },
+
+ 'list includes apps the user created': async (t) => {
+ await t.puter.apps.create(
+ 'apps-suite-list',
+ 'https://example.com/list',
+ );
+ const apps = await t.puter.apps.list();
+ t.assert.ok(
+ apps.some((a) => a.name === 'apps-suite-list'),
+ 'created app should appear in list',
+ );
+ },
+
+ 'update changes the index URL': async (t) => {
+ await t.puter.apps.create(
+ 'apps-suite-update',
+ 'https://example.com/before',
+ );
+ const updated = await t.puter.apps.update('apps-suite-update', {
+ indexURL: 'https://example.com/after',
+ });
+ t.assert.equal(updated.index_url, 'https://example.com/after');
+ },
+
+ 'delete removes the app': async (t) => {
+ await t.puter.apps.create(
+ 'apps-suite-delete',
+ 'https://example.com/delete',
+ );
+ await t.puter.apps.delete('apps-suite-delete');
+ await t.assert.rejects(
+ () => t.puter.apps.get('apps-suite-delete'),
+ 'get of a deleted app should reject',
+ );
+ },
+});
diff --git a/src/puter-js/tests/api/suites/auth.suite.ts b/src/puter-js/tests/api/suites/auth.suite.ts
new file mode 100644
index 000000000..f9302d617
--- /dev/null
+++ b/src/puter-js/tests/api/suites/auth.suite.ts
@@ -0,0 +1,49 @@
+import { suite } from '../harness/types.ts';
+
+export default suite('auth', {
+ 'getUser returns the authenticated user': async (t) => {
+ const user = await t.puter.auth.getUser();
+ t.assert.equal(user.username, t.env.users.user.username);
+ },
+
+ 'isSignedIn reports true with a valid token': async (t) => {
+ t.assert.equal(t.puter.auth.isSignedIn(), true);
+ },
+
+ 'password login issues a working token': async (t) => {
+ const res = await fetch(`${t.env.origin}/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: t.env.origin,
+ },
+ body: JSON.stringify({
+ username: t.env.users.user.username,
+ password: t.env.users.user.password,
+ }),
+ });
+ t.assert.equal(res.status, 200);
+ const body = (await res.json()) as { proceed: boolean; token?: string };
+ t.assert.ok(body.token, 'login response should include a token');
+ },
+
+ 'regular user is rejected by admin-gated endpoints': async (t) => {
+ const asUser = await fetch(`${t.env.apiOrigin}/serverInfo`, {
+ headers: {
+ Authorization: `Bearer ${t.env.users.user.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ });
+ t.assert.equal(asUser.status, 403);
+ },
+
+ 'admin user passes admin-gated endpoints': async (t) => {
+ const asAdmin = await fetch(`${t.env.apiOrigin}/serverInfo`, {
+ headers: {
+ Authorization: `Bearer ${t.env.users.admin.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ });
+ t.assert.equal(asAdmin.status, 200);
+ },
+});
diff --git a/src/puter-js/tests/api/suites/fs.suite.ts b/src/puter-js/tests/api/suites/fs.suite.ts
new file mode 100644
index 000000000..de41b62a7
--- /dev/null
+++ b/src/puter-js/tests/api/suites/fs.suite.ts
@@ -0,0 +1,47 @@
+import { suite } from '../harness/types.ts';
+
+const home = (t: { env: { users: { user: { username: string } } } }) =>
+ `/${t.env.users.user.username}`;
+
+export default suite('fs', {
+ 'write creates a file and read returns its content': async (t) => {
+ const path = `${home(t)}/fs-suite-roundtrip.txt`;
+ await t.puter.fs.write(path, 'hello from the suite');
+ const blob = await t.puter.fs.read(path);
+ t.assert.equal(await blob.text(), 'hello from the suite');
+ },
+
+ 'stat reports name and type': async (t) => {
+ const path = `${home(t)}/fs-suite-stat.txt`;
+ await t.puter.fs.write(path, 'stat me');
+ const info = await t.puter.fs.stat(path);
+ t.assert.equal(info.name, 'fs-suite-stat.txt');
+ t.assert.equal(Boolean(info.is_dir), false);
+ },
+
+ 'mkdir creates a directory listable via readdir': async (t) => {
+ const dir = `${home(t)}/fs-suite-dir`;
+ await t.puter.fs.mkdir(dir);
+ await t.puter.fs.write(`${dir}/inside.txt`, 'x');
+ const entries = await t.puter.fs.readdir(dir);
+ t.assert.equal(entries.length, 1);
+ t.assert.equal(entries[0].name, 'inside.txt');
+ },
+
+ 'delete removes a file': async (t) => {
+ const path = `${home(t)}/fs-suite-delete.txt`;
+ await t.puter.fs.write(path, 'ephemeral');
+ await t.puter.fs.delete(path);
+ await t.assert.rejects(
+ () => t.puter.fs.stat(path),
+ 'stat of a deleted file should reject',
+ );
+ },
+
+ 'users cannot read files outside their home': async (t) => {
+ await t.assert.rejects(
+ () => t.puter.fs.readdir(`/${t.env.users.admin.username}`),
+ "reading another user's home should reject",
+ );
+ },
+});
diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts
new file mode 100644
index 000000000..145d8f672
--- /dev/null
+++ b/src/puter-js/tests/api/suites/index.ts
@@ -0,0 +1,11 @@
+import type { Suite } from '../harness/types.ts';
+import apps from './apps.suite.ts';
+import auth from './auth.suite.ts';
+import fs from './fs.suite.ts';
+import kv from './kv.suite.ts';
+
+/**
+ * Explicit registry (no dynamic globbing) so the same list is visible to
+ * the node runner and to esbuild when bundling for browsers and workerd.
+ */
+export const suites: Suite[] = [apps, auth, fs, kv];
diff --git a/src/puter-js/tests/api/suites/kv.suite.ts b/src/puter-js/tests/api/suites/kv.suite.ts
new file mode 100644
index 000000000..ecc110835
--- /dev/null
+++ b/src/puter-js/tests/api/suites/kv.suite.ts
@@ -0,0 +1,30 @@
+import { suite } from '../harness/types.ts';
+
+export default suite('kv', {
+ 'set then get round-trips a string': async (t) => {
+ t.assert.equal(await t.puter.kv.set('kv-suite-str', 'value'), true);
+ t.assert.equal(await t.puter.kv.get('kv-suite-str'), 'value');
+ },
+
+ 'set then get round-trips an object': async (t) => {
+ await t.puter.kv.set('kv-suite-obj', { nested: { n: 1 } });
+ t.assert.deepEqual(await t.puter.kv.get('kv-suite-obj'), {
+ nested: { n: 1 },
+ });
+ },
+
+ 'get of a missing key returns null': async (t) => {
+ t.assert.equal(await t.puter.kv.get('kv-suite-missing'), null);
+ },
+
+ 'del removes a key': async (t) => {
+ await t.puter.kv.set('kv-suite-del', 'x');
+ t.assert.equal(await t.puter.kv.del('kv-suite-del'), true);
+ t.assert.equal(await t.puter.kv.get('kv-suite-del'), null);
+ },
+
+ 'incr counts up': async (t) => {
+ t.assert.equal(await t.puter.kv.incr('kv-suite-counter'), 1);
+ t.assert.equal(await t.puter.kv.incr('kv-suite-counter'), 2);
+ },
+});
diff --git a/src/puter-js/tests/api/vitest.config.ts b/src/puter-js/tests/api/vitest.config.ts
new file mode 100644
index 000000000..85f806d91
--- /dev/null
+++ b/src/puter-js/tests/api/vitest.config.ts
@@ -0,0 +1,40 @@
+import path from 'node:path';
+import { defineConfig } from 'vitest/config';
+import { lowerDecoratorsPlugin } from '../../../backend/vitest.config.ts';
+
+// Config for the client-agnostic puter.js API suites (tests/api). The
+// runners boot the in-memory backend in-process, so this mirrors the
+// backend vitest setup: same decorator lowering, same path aliases, and
+// repo root as vitest root so backend + extensions sources get transformed.
+const apiTestsDir = __dirname;
+const repoRoot = path.resolve(apiTestsDir, '../../../..');
+const backendDir = path.join(repoRoot, 'src/backend');
+
+export default defineConfig({
+ plugins: [lowerDecoratorsPlugin],
+ resolve: {
+ alias: [
+ {
+ find: /^@heyputer\/backend\/src\/(.*)$/,
+ replacement: path.join(backendDir, '$1'),
+ },
+ {
+ find: /^@heyputer\/backend\/(.*)$/,
+ replacement: path.join(backendDir, '$1'),
+ },
+ {
+ find: /^@heyputer\/backend$/,
+ replacement: path.join(backendDir, 'exports.ts'),
+ },
+ ],
+ },
+ test: {
+ globals: true,
+ include: ['src/puter-js/tests/api/runners/*.test.{js,ts}'],
+ // Server boot + SDK/worker bundling happen in hooks; browser and
+ // workerd runners are slower than unit tests.
+ testTimeout: 60_000,
+ hookTimeout: 120_000,
+ root: repoRoot,
+ },
+});