tests: more tests for puter.js (#3402)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-07-16 22:35:15 -07:00
committed by GitHub
parent c188257f85
commit 7faaef8cf0
10 changed files with 292 additions and 0 deletions
+22
View File
@@ -119,4 +119,26 @@ export default suite('ai', {
'the internal fake-chat provider should stay hidden',
);
},
'every model reported by the driver carries an id': async (t) => {
// The public listModels endpoint is empty without provider keys, so
// assert the shape against the driver-level list (always populated
// with the fake models).
const resp = await t.puter.drivers.call(
'puter-chat-completion',
'ai-chat',
'models',
{},
);
const models = (resp.result ?? resp) as Array<{ id?: string }>;
t.assert.ok(Array.isArray(models), 'driver models should be an array');
t.assert.ok(models.length > 0, 'driver should report models');
for (const model of models) {
t.assert.equal(
typeof model.id,
'string',
`every model entry should expose an id, got ${JSON.stringify(model)}`,
);
}
},
});
@@ -83,6 +83,30 @@ export default suite('auth', {
);
},
'getDetailedAppUsage without an appId rejects': async (t) => {
await t.assert.rejects(
() =>
(
t.puter.auth.getDetailedAppUsage as (
appId?: unknown,
) => Promise<unknown>
)(),
'getDetailedAppUsage should require an appId',
);
},
'getDetailedAppUsage returns a report for an app': async (t) => {
const app = await t.puter.apps.create(
t.puter.randName(),
'https://example.com/',
);
const usage = await t.puter.auth.getDetailedAppUsage(app.uid);
t.assert.ok(
usage && typeof usage === 'object',
'detailed usage should be an object',
);
},
'regular user is rejected by admin-gated endpoints': async (t) => {
const asUser = await fetch(`${t.env.apiOrigin}/serverInfo`, {
headers: {
+29
View File
@@ -327,6 +327,35 @@ export default suite('fs', {
t.assert.equal(await blob.text(), 'upload two');
},
'read of a directory rejects': async (t) => {
const dir = `${home(t)}/fs-suite-read-dir`;
await t.puter.fs.mkdir(dir);
await t.assert.rejects(
() => t.puter.fs.read(dir),
'reading a directory as a file should reject',
);
},
'stat reports uid, path and size for a file': async (t) => {
const path = `${home(t)}/fs-suite-stat-fields.txt`;
await t.puter.fs.write(path, 'twelve bytes');
const info = await t.puter.fs.stat(path);
t.assert.ok(info.uid, 'stat should return a uid');
t.assert.equal(Boolean(info.is_dir), false);
t.assert.equal(info.name, 'fs-suite-stat-fields.txt');
t.assert.ok(info.path.endsWith('fs-suite-stat-fields.txt'));
t.assert.equal(Number(info.size), 'twelve bytes'.length);
},
'copy into a missing destination directory rejects': async (t) => {
const src = `${home(t)}/fs-suite-copy-src.txt`;
await t.puter.fs.write(src, 'copy me');
await t.assert.rejects(
() => t.puter.fs.copy(src, `${home(t)}/fs-suite-copy-nope`),
'copy into a nonexistent directory should reject',
);
},
'users cannot read files outside their home': async (t) => {
await t.assert.rejects(
() => t.puter.fs.readdir(`/${t.env.users.admin.username}`),
@@ -98,6 +98,26 @@ export default suite('hosting', {
);
},
'get of an unknown subdomain rejects': async (t) => {
await t.assert.rejects(
() => t.puter.hosting.get('hosting-suite-never-created'),
'get of an unknown subdomain should reject',
);
},
'update to a missing directory rejects': async (t) => {
const dir = await makeSiteDir(t, 'update-missing');
await t.puter.hosting.create('hosting-suite-update-missing', dir);
await t.assert.rejects(
() =>
t.puter.hosting.update(
'hosting-suite-update-missing',
`${home(t)}/hosting-suite-not-a-dir`,
),
'update pointing at a missing directory should reject',
);
},
'delete removes the subdomain': async (t) => {
const dir = await makeSiteDir(t, 'delete');
await t.puter.hosting.create('hosting-suite-delete', dir);
+2
View File
@@ -8,6 +8,7 @@ import kv from './kv.suite.ts';
import net from './net.suite.ts';
import perms from './perms.suite.ts';
import system from './system.suite.ts';
import util from './util.suite.ts';
import workers from './workers.suite.ts';
/**
@@ -24,5 +25,6 @@ export const suites: Suite[] = [
net,
perms,
system,
util,
workers,
];
+90
View File
@@ -132,4 +132,94 @@ export default suite('kv', {
'an undefined key should be rejected',
);
},
'MAX_KEY_SIZE and MAX_VALUE_SIZE expose the documented limits': async (
t,
) => {
t.assert.equal(t.puter.kv.MAX_KEY_SIZE, 1024);
t.assert.equal(t.puter.kv.MAX_VALUE_SIZE, 399 * 1024);
},
'incr on a fresh key starts from zero': async (t) => {
t.assert.equal(await t.puter.kv.incr('kv-suite-incr-fresh', 3), 3);
},
'decr can drive a value negative': async (t) => {
t.assert.equal(await t.puter.kv.decr('kv-suite-decr-neg', 5), -5);
},
'add appends values into an array at a path': async (t) => {
await t.puter.kv.set('kv-suite-add', { tags: ['alpha'] });
const updated = await t.puter.kv.add('kv-suite-add', {
tags: ['beta', 'gamma'],
});
t.assert.deepEqual(updated.tags, ['alpha', 'beta', 'gamma']);
},
'update with a ttl keeps the value readable before it expires': async (
t,
) => {
await t.puter.kv.set('kv-suite-update-ttl', { n: 1 });
await t.puter.kv.update('kv-suite-update-ttl', { n: 2 }, 3600);
const value = await t.puter.kv.get('kv-suite-update-ttl');
t.assert.equal(value.n, 2);
},
'list without a pattern returns every key for the app': async (t) => {
await t.puter.kv.set('kv-suite-all-1', 1);
await t.puter.kv.set('kv-suite-all-2', 2);
const keys = (await t.puter.kv.list()) as string[];
t.assert.ok(keys.includes('kv-suite-all-1'));
t.assert.ok(keys.includes('kv-suite-all-2'));
},
'list returns keys in lexicographic order': async (t) => {
await t.puter.kv.set('kv-suite-sorted-c', 1);
await t.puter.kv.set('kv-suite-sorted-a', 1);
await t.puter.kv.set('kv-suite-sorted-b', 1);
const keys = (await t.puter.kv.list('kv-suite-sorted-*')) as string[];
t.assert.deepEqual(keys, [
'kv-suite-sorted-a',
'kv-suite-sorted-b',
'kv-suite-sorted-c',
]);
},
'list with a limit and cursor paginates through matches': async (t) => {
for (let i = 1; i <= 3; i++) {
await t.puter.kv.set(`kv-suite-page-${i}`, `v${i}`);
}
const seen: string[] = [];
let cursor: string | undefined;
let guard = 0;
do {
const page = (await t.puter.kv.list({
pattern: 'kv-suite-page-*',
returnValues: true,
limit: 2,
cursor,
})) as { items: Array<{ key: string }>; cursor?: string };
for (const item of page.items) seen.push(item.key);
cursor = page.cursor;
} while (cursor && ++guard < 10);
t.assert.deepEqual(seen.sort(), [
'kv-suite-page-1',
'kv-suite-page-2',
'kv-suite-page-3',
]);
},
'flush removes every key for the app': async (t) => {
await t.puter.kv.set('kv-suite-flush-a', 1);
await t.puter.kv.set('kv-suite-flush-b', 2);
await t.puter.kv.flush();
t.assert.equal(await t.puter.kv.get('kv-suite-flush-a'), null);
t.assert.equal(await t.puter.kv.get('kv-suite-flush-b'), null);
const keys = (await t.puter.kv.list()) as string[];
t.assert.equal(
keys.some((k) => k.startsWith('kv-suite-')),
false,
'flush should clear every key the suite created',
);
},
});
@@ -123,6 +123,24 @@ export default suite('perms', {
t.assert.equal(await res.text(), 'group content');
},
'grantGroup then revokeGroup both succeed': async (t) => {
const path = `${home(t)}/perms-suite-group-revoke.txt`;
await t.puter.fs.write(path, 'group revoke content');
const permission = `fs:${path}:read`;
const created = await t.puter.perms.createGroup({
title: 'perms-suite-revoke-readers',
});
await t.puter.perms.addUsersToGroup(created.uid, [
t.env.users.other.username,
]);
const granted = await t.puter.perms.grantGroup(created.uid, permission);
t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`);
const revoked = await t.puter.perms.revokeGroup(created.uid, permission);
t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`);
},
'grantApp records an app permission': async (t) => {
const app = await t.puter.apps.create(
'perms-suite-app',
@@ -20,6 +20,7 @@ export default suite('system', {
'puter-kvstore',
'puter-apps',
'puter-subdomains',
'puter-chat-completion',
]) {
t.assert.ok(
Object.prototype.hasOwnProperty.call(interfaces, expected),
@@ -0,0 +1,43 @@
import { suite } from '../harness/types.ts';
/**
* puter.js utility helpers (`puter.randName`, `puter.env`). These are pure
* client-side helpers, so they run identically on every platform.
* DOM-bound utilities like `puter.print` are covered by the browser
* fixtures, not here.
*/
export default suite('util', {
'randName returns a domain-safe name': async (t) => {
const name = t.puter.randName();
t.assert.equal(typeof name, 'string');
t.assert.ok(name.length > 0, 'randName should be non-empty');
t.assert.ok(
/^[a-z0-9-]+$/.test(name),
`randName should be lowercase, digits and dashes only, got: ${name}`,
);
},
'randName produces a fresh name each call': async (t) => {
const a = t.puter.randName();
const b = t.puter.randName();
t.assert.ok(a !== b, 'two randName calls should differ');
},
'randName honours a custom separator': async (t) => {
const name = t.puter.randName('_');
t.assert.ok(
name.includes('_') && !name.includes('-'),
`custom separator should be used throughout, got: ${name}`,
);
},
'env reports the runtime environment': async (t) => {
const env = t.puter.env;
t.assert.ok(
['web', 'app', 'gui', 'nodejs', 'web-worker', 'service-worker'].includes(
env,
),
`env should be a known environment, got: ${env}`,
);
},
});
@@ -12,6 +12,17 @@ const home = (t: TestContext) => `/${t.env.users.user.username}`;
*/
const WORKER_SOURCE = `
router.custom('GET', '/ping', async () => ({ pong: true }));
router.post('/echo', async ({ request }) => {
const body = await request.json();
return { echoed: body };
});
router.get('/posts/:category/:id', async ({ params }) => params);
router.get('/teapot', async () => new Response('no coffee', { status: 418 }));
router.get('/whoami', async ({ user }) => {
if (!user || !user.puter) return { authed: false };
const me = await user.puter.getUser();
return { authed: true, username: me.username };
});
`;
const deployWorker = async (t: TestContext, name: string) => {
@@ -35,6 +46,38 @@ export default suite('workers', {
t.assert.deepEqual(body, { pong: true });
},
'exec POSTs a body and reads the JSON response': async (t) => {
const created = await deployWorker(t, 'workers-suite-echo');
const res = await t.puter.workers.exec(`${created.url}/echo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'worker' }),
});
t.assert.equal(res.status, 200);
t.assert.deepEqual(await res.json(), { echoed: { hello: 'worker' } });
},
'a worker resolves route parameters': async (t) => {
const created = await deployWorker(t, 'workers-suite-params');
const res = await t.puter.workers.exec(`${created.url}/posts/tech/42`);
t.assert.deepEqual(await res.json(), { category: 'tech', id: '42' });
},
'a worker can return a custom status code': async (t) => {
const created = await deployWorker(t, 'workers-suite-status');
const res = await t.puter.workers.exec(`${created.url}/teapot`);
t.assert.equal(res.status, 418);
t.assert.equal(await res.text(), 'no coffee');
},
'exec runs the worker in the calling user context': async (t) => {
const created = await deployWorker(t, 'workers-suite-userctx');
const res = await t.puter.workers.exec(`${created.url}/whoami`);
const body = await res.json();
t.assert.equal(body.authed, true, 'user.puter should be populated');
t.assert.equal(body.username, t.env.users.user.username);
},
'get returns the deployed worker': async (t) => {
await deployWorker(t, 'workers-suite-get');
const worker = await t.puter.workers.get('workers-suite-get');