wip: puter js tests structure (#3393)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-07-15 19:09:35 -07:00
committed by GitHub
parent c237e2433f
commit 52e481128f
27 changed files with 1361 additions and 116 deletions
+11 -2
View File
@@ -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
+53
View File
@@ -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'
+4
View File
@@ -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",
+12 -5
View File
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
+149 -100
View File
@@ -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=<gui-origin>`)
// 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<void>((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=<gui-origin>`)
// 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<void>((resolve) => {
this.#server!.close(() => resolve());
});
this.#server.closeAllConnections();
await closed;
for (const client of Object.values(
this.clients,
) as WithLifecycle[]) {
@@ -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;
/**
+41
View File
@@ -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);
});
+199 -7
View File
@@ -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<number> =>
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<IConfig> => {
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<PuterServer> => {
// 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.<domain>` 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<TestUserCredentials> => {
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:<port>`) — GUI and root-only
* routes like `POST /login` live here.
*/
origin: string;
/**
* API origin (`http://api.puter.localhost:<port>`) — 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<void>;
};
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<PuterTestEnv> => {
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;
}
};
+6
View File
@@ -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;
+3 -1
View File
@@ -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) {
+83
View File
@@ -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/<name>.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.
+74
View File
@@ -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<string, unknown>)
.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<unknown>,
message?: string,
): Promise<unknown> {
try {
await fn();
} catch (e) {
return e;
}
throw new AssertionError(message ?? 'expected promise to reject');
},
};
export type Assert = typeof assert;
@@ -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<RunTestResult>;
}
}
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);
};
@@ -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<string> => {
const result = await build({
entryPoints: [fileURLToPath(entryUrl)],
bundle: true,
write: false,
format: 'iife',
platform: 'browser',
target: 'es2022',
});
return result.outputFiles[0].text;
};
@@ -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<RunTestResult> => {
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<string, unknown>).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,
})),
);
@@ -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<string, unknown> = {};
for (const name of Object.getOwnPropertyNames(globalThis)) {
try {
context[name] = (globalThis as Record<string, unknown>)[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;
};
+59
View File
@@ -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<void>;
/** Outcome of a single test run, serializable across runtime boundaries. */
export type RunTestResult = {
ok: boolean;
error?: string;
};
export type Suite = {
name: string;
tests: Record<string, SuiteTest>;
};
export const suite = (
name: string,
tests: Record<string, SuiteTest>,
): Suite => ({ name, tests });
@@ -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<RunTestResult> => {
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);
});
@@ -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 = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>puter.js suite fixture</title>
<script>
window.PUTER_API_ORIGIN = ${JSON.stringify(env.apiOrigin)};
window.PUTER_ORIGIN = ${JSON.stringify(env.origin)};
</script>
<script src="/puter.js/v2"></script>
<script>${bundle}</script>
</head>
<body></body>
</html>`;
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<RunTestResult, RunTestArgs>(
(args) => window.__runSuiteTest__(args),
{ suiteName, testName, env: manifest, platform: 'browser' },
);
expect(result.error ?? '').toBe('');
expect(result.ok).toBe(true);
});
}
});
@@ -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);
});
}
});
@@ -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
// `<name>.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);
});
}
});
@@ -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',
);
},
});
@@ -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);
},
});
+47
View File
@@ -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",
);
},
});
+11
View File
@@ -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];
+30
View File
@@ -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);
},
});
+40
View File
@@ -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,
},
});