mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
tests: more tests for puter.js (#3402)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user