mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 07:27:04 +00:00
wip: puter js tests structure (#3393)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user