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, + }, +});