diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index 300c2b7fbb..b7f987416e 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -9,6 +9,7 @@ import upload from "./operations/upload.js"; import read from "./operations/read.js"; import move from "./operations/move.js"; import write from "./operations/write.js"; +import share from "./operations/share.js"; import sign from "./operations/sign.js"; import symlink from './operations/symlink.js'; // Why is this called deleteFSEntry instead of just delete? because delete is @@ -34,6 +35,7 @@ export class PuterJSFileSystemModule extends AdvancedBase { sign = sign; symlink = symlink; getReadURL = getReadURL; + share = share; FSItem = FSItem diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js new file mode 100644 index 0000000000..d031274fba --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/share.js @@ -0,0 +1,59 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; + +const share = async function (targetPath, options = {}) { + // targetPath is required + if (!targetPath) { + throw new Error('No target path provided.'); + } + + // If targetPath is not provided or it's not starting with a slash, it means it's a relative path + // in that case, we need to prepend the app's root directory to it + targetPath = getAbsolutePathForApp(targetPath); + + // Extract options + const recipients = options.recipients || []; + const access = options.access || 'read'; + + // Validate access level + if (!['read', 'write'].includes(access)) { + throw new Error('Invalid access level. Must be "read" or "write".'); + } + + // Validate recipients + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new Error('Recipients must be a non-empty array.'); + } + + // Prepare the share request + const shareData = { + recipients: recipients, + shares: [ + { + $: 'fs-share', + path: targetPath, + access: access, + } + ] + }; + + // Make the API call to share the file + console.log(`api origin: ${puter.APIOrigin}`); + const response = await fetch(`${puter.APIOrigin}/share`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}` + }, + body: JSON.stringify(shareData) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Share failed: ${response.status} ${errorText}`); + } + + const result = await response.json(); + return result; +}; + +export default share; diff --git a/src/puter-js/src/modules/FileSystem/operations/write.js b/src/puter-js/src/modules/FileSystem/operations/write.js index 878e61050c..0dcaa9d023 100644 --- a/src/puter-js/src/modules/FileSystem/operations/write.js +++ b/src/puter-js/src/modules/FileSystem/operations/write.js @@ -1,4 +1,4 @@ -import path from "../../../lib/path.js" +import path from "../../../lib/path.js"; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; const write = async function (targetPath, data, options = {}) { @@ -56,7 +56,35 @@ const write = async function (targetPath, data, options = {}) { } // perform upload - return this.upload(data, parent, options); + const result = await this.upload(data, parent, options); + + if (options.share) { + try { + // Call the share API after successful write + let share_result = await this.share(result.path, { + recipients: options.share.recipients || [], + access: options.share.access || 'read' + }); + console.log('share_result', share_result); + + // Add share information to the result + result.share = { + status: share_result.status, + recipients: share_result.recipients || [], + shares: share_result.shares || [] + }; + } catch (error) { + console.error('Failed to share file after write:', error); + // Add error information to the result + result.share = { + status: 'error', + error: error.message, + recipients: [] + }; + } + } + + return result; } export default write; \ No newline at end of file diff --git a/tools/api-tester/README.md b/tools/api-tester/README.md index 95d4eaebf5..44b47e7865 100644 --- a/tools/api-tester/README.md +++ b/tools/api-tester/README.md @@ -1,6 +1,6 @@ # API Tester -A test framework for testing the backend API of puter. +A test framework for testing the API of puter backend and puter-js client. ## Table of Contents @@ -43,6 +43,12 @@ All commands below should be run from the root directory of puter. node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml ``` +4. (experimental) Run tests against the puter-js client: + + ```bash + node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml --client + ``` + ### Shorthands - Run unit tests only: diff --git a/tools/api-tester/apitest.js b/tools/api-tester/apitest.js index b5dcb9d734..9b6c5125d3 100644 --- a/tools/api-tester/apitest.js +++ b/tools/api-tester/apitest.js @@ -9,13 +9,14 @@ const { parseArgs } = require('node:util'); const args = process.argv.slice(2); -let config, report, suiteName; +let config, report, suiteName, onlycase, bench, unit, stopOnFailure, id, puterjs; try { const parsed = parseArgs({ options: { config: { type: 'string', + default: './tools/api-tester/config.yml', }, report: { type: 'string', @@ -24,6 +25,8 @@ try { bench: { type: 'boolean' }, unit: { type: 'boolean' }, suite: { type: 'string' }, + 'stop-on-failure': { type: 'boolean' }, + puterjs: { type: 'boolean' }, }, allowPositionals: true, }); @@ -35,6 +38,8 @@ try { bench, unit, suite: suiteName, + 'stop-on-failure': stopOnFailure, + puterjs, }, positionals: [id] } = parsed); onlycase = onlycase !== undefined ? Number.parseInt(onlycase) : undefined; @@ -47,6 +52,7 @@ try { '\n' + 'Options:\n' + ' --config= (required) Path to configuration file\n' + + ' --puterjs (optional) Use puter-js puterjs\n' + ' --report= (optional) Output file for full test results\n' + ' --suite= (optional) Run only tests with matching suite name\n' + '' @@ -58,6 +64,87 @@ const conf = YAML.parse(fs.readFileSync(config).toString()); const main = async () => { + if (puterjs) { + // const run = require('./puter_js/__entry__.js'); + + const context = { + mountpoint: { + path: '/', + } + }; + + const ts = new TestSDK(conf, context, {}); + const registry = new TestRegistry(ts); + + await require('./puter_js/__entry__.js')(registry); + + await registry.run_all_tests(); + + // await run(conf); + ts.printTestResults(); + ts.printBenchmarkResults(); + process.exit(0); + return; + } + + const unit_test_results = []; + const benchmark_results = []; + for (const mountpoint of conf.mountpoints) { + const { unit_test_results: results, benchmark_results: benchs } = await test({ mountpoint }); + unit_test_results.push(...results); + benchmark_results.push(...benchs); + } + + // hard-coded identifier for ci script + console.log("==================== nightly build results begin ====================") + + // print unit test results + let tbl = {}; + for ( const result of unit_test_results ) { + tbl[result.name + ' - ' + result.settings] = { + passed: result.caseCount - result.failCount, + failed: result.failCount, + total: result.caseCount, + 'duration (s)': result.duration ? result.duration.toFixed(2) : 'N/A', + } + } + console.table(tbl); + + // print benchmark results + if (benchmark_results.length > 0) { + tbl = {}; + for ( const result of benchmark_results ) { + const fs_provider = result.fs_provider || 'unknown'; + tbl[result.name + ' - ' + fs_provider] = { + 'duration (s)': result.duration ? (result.duration / 1000).toFixed(2) : 'N/A', + } + } + console.table(tbl); + + // print description of each benchmark since it's too long to fit in the table + const seen = new Set(); + for ( const result of benchmark_results ) { + if ( seen.has(result.name) ) continue; + seen.add(result.name); + + if ( result.description ) { + console.log(result.name + ': ' + result.description); + } + } + } + + // hard-coded identifier for ci script + console.log("==================== nightly build results end ====================") +} + +/** + * Run test using the given config, and return the test results + * + * @param {Object} options + * @param {Object} options.mountpoint + * @returns {Promise} + */ +async function test({ mountpoint }) { const context = { options: { onlycase, diff --git a/tools/api-tester/puter_js/__entry__.js b/tools/api-tester/puter_js/__entry__.js new file mode 100644 index 0000000000..3e8da689b4 --- /dev/null +++ b/tools/api-tester/puter_js/__entry__.js @@ -0,0 +1,27 @@ +const load_puterjs = require('./load.cjs'); + +async function run(conf) { + const puter = await load_puterjs(); + if (conf.token) { + puter.setAuthToken(conf.token); + } else { + throw new Error('No token found in config file. Please add a "token" field to your config.yaml'); + } + return; +}; + +module.exports = async registry => { + const puter = await load_puterjs(); + if (registry.t?.conf?.token) { + puter.setAuthToken(registry.t.conf.token); + } else { + throw new Error('No token found in config file. Please add a "token" field to your config.yaml'); + } + + registry.t.puter = puter; + + console.log('__entry__.js'); + require('./filesystem/__entry__.js')(registry); + + // registry.add_test('filesystem', require('./filesystem/__entry__.js')); +}; \ No newline at end of file diff --git a/tools/api-tester/puter_js/filesystem/__entry__.js b/tools/api-tester/puter_js/filesystem/__entry__.js new file mode 100644 index 0000000000..e56c81ceb4 --- /dev/null +++ b/tools/api-tester/puter_js/filesystem/__entry__.js @@ -0,0 +1,4 @@ +module.exports = registry => { + console.log('filesystem __entry__.js'); + registry.add_test('write', require('./write.js')); +}; \ No newline at end of file diff --git a/tools/api-tester/puter_js/filesystem/write.js b/tools/api-tester/puter_js/filesystem/write.js new file mode 100644 index 0000000000..35a7107f60 --- /dev/null +++ b/tools/api-tester/puter_js/filesystem/write.js @@ -0,0 +1,40 @@ +const chai = require('chai'); +chai.use(require('chai-as-promised')) +const expect = chai.expect; + +module.exports = { + name: 'write', + description: 'a collection of tests for writing to the filesystem', + do: async t => { + const puter = t.puter; + + await t.case('demo (whoami)', async () => { + const result = await puter.auth.whoami(); + expect(result.username).to.equal('admin'); + }); + + await t.case('write and share', async () => { + let result = await puter.fs.write('~/test.txt', 'hello'); + expect(result.name).to.equal('test.txt'); + + result = await puter.fs.share('~/test.txt', { + recipients: ['tom', 'jerry'], + access: 'read', + withPermissions: true, + }); + console.log('result', result); + expect(result.recipients.length).to.equal(2); + }); + + await t.case('write with share args', async () => { + let result = await puter.fs.write('~/test.txt', 'hello', { + share: { + recipients: ['tom', 'jerry'], + access: 'read', + }, + withPermissions: true, + }); + expect(result.share.recipients.length).to.equal(2); + }); + } +} \ No newline at end of file diff --git a/tools/api-tester/puter_js/load.cjs b/tools/api-tester/puter_js/load.cjs new file mode 100644 index 0000000000..5cd17e008a --- /dev/null +++ b/tools/api-tester/puter_js/load.cjs @@ -0,0 +1,13 @@ +const vm = require('vm'); + +async function load_puterjs() { + const goodContext = {} + Object.getOwnPropertyNames(globalThis).forEach(name => { try { goodContext[name] = globalThis[name]; } catch { } }) + goodContext.globalThis = goodContext + const code = await fetch("http://puter.localhost:4100/puter.js/v2").then(res => res.text()); + const context = vm.createContext(goodContext); + const result = vm.runInNewContext(code, context); + return goodContext.puter; +} + +module.exports = load_puterjs; \ No newline at end of file