From 0e1be72f928c5d08d8f89dc49404a133905cd822 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 16 Jul 2026 18:52:37 -0400 Subject: [PATCH] test: tests for puter.js (#3396) * test: tests for puter.js * fix: ship lockfile for coverage devDeps; tolerate missing base coverage npm ci failed on CI because package.json gained the babel/istanbul devDependencies without the matching package-lock.json update. Also make the coverage workflow's base leg best-effort so a base ref that predates the coverage script reports without the comparison column instead of failing the run. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/puterjs-tests.yaml | 92 +++ package-lock.json | 588 ++++++++++++++++++ package.json | 8 + .../controllers/auth/AuthController.ts | 8 +- .../controllers/system/SystemController.js | 39 +- .../localworker/LocalWorkerService.ts | 25 +- src/backend/testUtil.ts | 14 +- src/puter-js/package.json | 1 + .../tests/api/harness/capabilities.ts | 105 ++++ src/puter-js/tests/api/harness/coverage.ts | 52 ++ src/puter-js/tests/api/harness/executor.ts | 51 +- .../tests/api/harness/nodeSdkLoader.ts | 26 +- src/puter-js/tests/api/harness/types.ts | 28 +- .../tests/api/harness/workerdEntry.ts | 10 +- .../tests/api/runners/browser.test.ts | 53 +- src/puter-js/tests/api/runners/node.test.ts | 27 +- .../tests/api/runners/workerd.test.ts | 39 +- src/puter-js/tests/api/suites/ai.suite.ts | 122 ++++ src/puter-js/tests/api/suites/apps.suite.ts | 81 +++ src/puter-js/tests/api/suites/auth.suite.ts | 56 ++ src/puter-js/tests/api/suites/fs.suite.ts | 304 ++++++++- .../tests/api/suites/hosting.suite.ts | 110 ++++ src/puter-js/tests/api/suites/index.ts | 19 +- src/puter-js/tests/api/suites/kv.suite.ts | 105 ++++ src/puter-js/tests/api/suites/net.suite.ts | 82 +++ src/puter-js/tests/api/suites/perms.suite.ts | 144 +++++ src/puter-js/tests/api/suites/system.suite.ts | 67 ++ .../tests/api/suites/workers.suite.ts | 60 ++ src/puter-js/tests/api/vitest.config.ts | 9 +- src/puter-js/webpack.config.js | 46 +- tools/puterjsCoverageReport.mjs | 60 ++ 31 files changed, 2358 insertions(+), 73 deletions(-) create mode 100644 src/puter-js/tests/api/harness/capabilities.ts create mode 100644 src/puter-js/tests/api/harness/coverage.ts create mode 100644 src/puter-js/tests/api/suites/ai.suite.ts create mode 100644 src/puter-js/tests/api/suites/hosting.suite.ts create mode 100644 src/puter-js/tests/api/suites/net.suite.ts create mode 100644 src/puter-js/tests/api/suites/perms.suite.ts create mode 100644 src/puter-js/tests/api/suites/system.suite.ts create mode 100644 src/puter-js/tests/api/suites/workers.suite.ts create mode 100644 tools/puterjsCoverageReport.mjs diff --git a/.github/workflows/puterjs-tests.yaml b/.github/workflows/puterjs-tests.yaml index d005665f9..2ada55b90 100644 --- a/.github/workflows/puterjs-tests.yaml +++ b/.github/workflows/puterjs-tests.yaml @@ -20,6 +20,7 @@ on: permissions: contents: read + pull-requests: write jobs: test: @@ -51,3 +52,94 @@ jobs: run: npm run test:puterjs env: CI: 'true' + + # SDK coverage on both refs, reported as a PR comment — same shape as + # backend-tests.yaml. This run uses the istanbul-instrumented bundle + # (the `test` job above keeps exercising the production build). + coverage: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - ref: ${{ github.base_ref }} + artifact: base + - ref: ${{ github.head_ref }} + artifact: pr + + steps: + - name: Checkout ${{ matrix.ref }} + uses: actions/checkout@v4 + with: + ref: ${{ matrix.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium + + # The base leg is best-effort — it only enriches the PR comment with + # a comparison, and the base ref may predate the coverage script. + - name: Run puter.js API tests with coverage + continue-on-error: ${{ matrix.artifact == 'base' }} + run: npm run test:puterjs:coverage + env: + CI: 'true' + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: puterjs-coverage-${{ matrix.artifact }} + path: src/puter-js/coverage/coverage-*.json + retention-days: 14 + if-no-files-found: ignore + + report-coverage: + needs: coverage + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download PR coverage + uses: actions/download-artifact@v4 + with: + name: puterjs-coverage-pr + path: src/puter-js/coverage + + # May not exist (base ref without the coverage script, or a failed + # base run) — then report without the comparison column. + - name: Download base coverage + id: base-coverage + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: puterjs-coverage-base + path: coverage-base + + - name: Report coverage on PR + if: steps.base-coverage.outcome == 'success' + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: puter.js SDK + json-summary-path: src/puter-js/coverage/coverage-summary.json + json-final-path: src/puter-js/coverage/coverage-final.json + json-summary-compare-path: coverage-base/coverage-summary.json + + - name: Report coverage on PR (no base comparison) + if: steps.base-coverage.outcome != 'success' + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: puter.js SDK + json-summary-path: src/puter-js/coverage/coverage-summary.json + json-final-path: src/puter-js/coverage/coverage-final.json diff --git a/package-lock.json b/package-lock.json index a786918cf..49223a315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "open": "^10.1.0" }, "devDependencies": { + "@babel/core": "^7.29.7", "@eslint/js": "^9.35.0", "@playwright/test": "^1.56.1", "@stylistic/eslint-plugin": "^5.3.1", @@ -36,6 +37,8 @@ "@typescript-eslint/parser": "^8.46.1", "@vitest/coverage-v8": "^4.0.14", "@vitest/ui": "^4.0.14", + "babel-loader": "^10.1.1", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.0", "clean-css": "^5.3.2", "dotenv": "^16.4.5", @@ -47,6 +50,9 @@ "globals": "^15.15.0", "html-webpack-plugin": "^5.6.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "license-check-and-add": "^4.0.5", "nodemon": "^3.1.0", "prettier": "^3.8.3", @@ -934,6 +940,192 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -954,6 +1146,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -979,6 +1195,40 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -3002,6 +3252,130 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3013,6 +3387,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -7685,6 +8070,52 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/babel-loader": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.1.tgz", + "integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0 || ^8.0.0-beta.1", + "@rspack/core": "^1.0.0 || ^2.0.0-0", + "webpack": ">=5.61.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -9850,6 +10281,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -10918,6 +11363,16 @@ "is-property": "^1.0.2" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10960,6 +11415,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -12013,6 +12478,23 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -12250,6 +12732,19 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -12333,6 +12828,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -16746,6 +17254,86 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", diff --git a/package.json b/package.json index 671d9962b..db09378c5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lib": "lib" }, "devDependencies": { + "@babel/core": "^7.29.7", "@eslint/js": "^9.35.0", "@playwright/test": "^1.56.1", "@stylistic/eslint-plugin": "^5.3.1", @@ -20,6 +21,8 @@ "@typescript-eslint/parser": "^8.46.1", "@vitest/coverage-v8": "^4.0.14", "@vitest/ui": "^4.0.14", + "babel-loader": "^10.1.1", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.0", "clean-css": "^5.3.2", "dotenv": "^16.4.5", @@ -31,6 +34,9 @@ "globals": "^15.15.0", "html-webpack-plugin": "^5.6.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "license-check-and-add": "^4.0.5", "nodemon": "^3.1.0", "prettier": "^3.8.3", @@ -50,6 +56,8 @@ "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", + "test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs", + "build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build", "start:gui": "nodemon --exec \"node dev-server.js\" ", "start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js", "prestart": "npm run setupExtensions && npm run build:ts", diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 75270c96c..c7b551663 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -3556,13 +3556,9 @@ export class AuthController extends PuterController { @Get('/group/list', { subdomain: 'api', requireUserActor: true }) async handleGroupList(req: Request, res: Response): Promise { const userId = req.actor!.user.id!; - const groupStore = this.stores.group as unknown as { - listByOwner: (id: number) => Promise; - listByMember: (id: number) => Promise; - }; const [owned, member] = await Promise.all([ - groupStore.listByOwner(userId), - groupStore.listByMember(userId), + this.stores.group.listGroupsWithOwner(userId), + this.stores.group.listGroupsWithMember(userId), ]); res.json({ owned_groups: owned, diff --git a/src/backend/controllers/system/SystemController.js b/src/backend/controllers/system/SystemController.js index ab57da149..1c30739fe 100644 --- a/src/backend/controllers/system/SystemController.js +++ b/src/backend/controllers/system/SystemController.js @@ -157,28 +157,27 @@ export class SystemController extends PuterController { }); }); - // -- GET /lsmod ---------------------------------------------- - // Enumerates driver interfaces and their implementors. + // -- GET|POST /lsmod ----------------------------------------- + // Enumerates driver interfaces and their implementors. POST is + // also routed because puter.js `drivers.list()` sends POST. - router.get( - '/lsmod', - { subdomain: 'api', requireAuth: true }, - (_req, res) => { - const interfaces = {}; - for (const [key, driver] of Object.entries(this.drivers)) { - const ifaceName = driver?.driverInterface; - if (!ifaceName) continue; - const driverName = driver.driverName ?? key; - if (!interfaces[ifaceName]) { - interfaces[ifaceName] = { implementors: {} }; - } - interfaces[ifaceName].implementors[driverName] = { - isDefault: Boolean(driver.isDefault), - }; + const lsmod = (_req, res) => { + const interfaces = {}; + for (const [key, driver] of Object.entries(this.drivers)) { + const ifaceName = driver?.driverInterface; + if (!ifaceName) continue; + const driverName = driver.driverName ?? key; + if (!interfaces[ifaceName]) { + interfaces[ifaceName] = { implementors: {} }; } - res.json({ interfaces }); - }, - ); + interfaces[ifaceName].implementors[driverName] = { + isDefault: Boolean(driver.isDefault), + }; + } + res.json({ interfaces }); + }; + router.get('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod); + router.post('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod); } onServerStart() {} diff --git a/src/backend/services/localworker/LocalWorkerService.ts b/src/backend/services/localworker/LocalWorkerService.ts index d82c6d2e4..c790202f3 100644 --- a/src/backend/services/localworker/LocalWorkerService.ts +++ b/src/backend/services/localworker/LocalWorkerService.ts @@ -42,11 +42,25 @@ export class LocalWorkerService extends PuterService { } as WorkerOptions); activeWorkers.set(workerName, mf); this.#touch(workerName); - return { success: true, errors: [], url: null }; + return { + success: true, + errors: [], + url: this.#localWorkerUrl(workerName), + }; } catch (_e) { return { success: false, errors: [], url: null }; } } + + /** + * Local analogue of the production worker URL, matching the host the + * local worker proxy dispatches on (`.workers.puter.localhost`). + * Clients rely on `create` returning a usable `url`. + */ + #localWorkerUrl(workerName: string): string { + const port = this.config.port ? `:${this.config.port}` : ''; + return `http://${workerName}.workers.puter.localhost${port}`; + } async cfCallLocal(workerName: string, request: Request) { let mf = activeWorkers.get(workerName); if (!mf) { @@ -81,7 +95,14 @@ export class LocalWorkerService extends PuterService { } async cfDeleteLocal(workerName: string) { await this.#disposeWorker(workerName); - return {}; + // Mirror the Cloudflare delete response shape — puter.js checks + // `result` to decide whether the delete succeeded. + return { + success: true, + errors: [], + messages: [], + result: { id: workerName }, + }; } // -- Idle lifecycle stuff diff --git a/src/backend/testUtil.ts b/src/backend/testUtil.ts index ad76d1ef3..65e25f452 100644 --- a/src/backend/testUtil.ts +++ b/src/backend/testUtil.ts @@ -265,10 +265,15 @@ export type PuterTestEnv = { * (e.g. `/whoami`) only match this host. */ apiOrigin: string; - /** Seeded accounts: an admin and a regular (non-privileged) user. */ + /** + * Seeded accounts: an admin and two regular (non-privileged) users. + * `other` exists so suites can exercise cross-user flows (permission + * grants, access denials) without creating users on the fly. + */ users: { admin: TestUserCredentials; user: TestUserCredentials; + other: TestUserCredentials; }; server: PuterServer; shutdown: () => Promise; @@ -282,6 +287,10 @@ export const TEST_USER_CREDENTIALS = { username: 'testuser', password: 'puter-test-user-password', }; +export const TEST_OTHER_USER_CREDENTIALS = { + username: 'otheruser', + password: 'puter-test-other-password', +}; /** * Boot an in-memory Puter server on a real ephemeral port with deterministic @@ -327,11 +336,12 @@ export const setupPuterTestEnv = async ( admin: true, }); const user = await createTestUser(server, TEST_USER_CREDENTIALS); + const other = await createTestUser(server, TEST_OTHER_USER_CREDENTIALS); return { origin, apiOrigin, - users: { admin, user }, + users: { admin, user, other }, server, shutdown: () => server.shutdown(), }; diff --git a/src/puter-js/package.json b/src/puter-js/package.json index 018521370..d0661464b 100644 --- a/src/puter-js/package.json +++ b/src/puter-js/package.json @@ -38,6 +38,7 @@ "test:e2e:report": "playwright show-report", "playwright:install": "playwright install chromium", "build": "webpack && { echo \"// Copyright 2024-present Puter Technologies Inc. All rights reserved.\"; echo \"// Generated on $(date '+%Y-%m-%d %H:%M')\n\"; cat ./dist/puter.js; } > temp && mv temp ./dist/puter.js", + "build:coverage": "webpack --env coverage", "prepublishOnly": "npm run build && mv dist/puter.js dist/puter.cjs && npm version patch" }, "author": "Puter Technologies Inc.", diff --git a/src/puter-js/tests/api/harness/capabilities.ts b/src/puter-js/tests/api/harness/capabilities.ts new file mode 100644 index 000000000..546adbddf --- /dev/null +++ b/src/puter-js/tests/api/harness/capabilities.ts @@ -0,0 +1,105 @@ +/** + * Optional test capabilities, derived from `PUTER_TEST_*` env vars on the + * node side before the test server boots. Each mapping contributes a + * capability tag that suite tests can declare via `requires`, plus the + * config override that makes the backend actually support it. Unset vars + * mean the dependent tests show up as skipped — a keyless run stays green + * without silently losing sight of what wasn't covered. + * + * Env var names follow the backend AI integration tests + * (`PUTER_TEST_AI__API_KEY`, see drivers/integrationTestUtil.ts) + * so one set of secrets drives both test layers. + * + * Node-side only: imported by the runners, never bundled into suites. + * Suites can read the resulting tags from `t.env.capabilities`. + */ + +type ConfigOverrides = Record; + +type CapabilityMapping = { + capability: string; + /** All must be set for the capability to activate. */ + envVars: string[]; + configOverrides: (values: string[]) => ConfigOverrides; +}; + +const aiProvider = ( + capability: string, + envVar: string, + providerId: string, +): CapabilityMapping => ({ + capability, + envVars: [envVar], + configOverrides: ([apiKey]) => ({ + providers: { [providerId]: { apiKey } }, + }), +}); + +const MAPPINGS: CapabilityMapping[] = [ + { + capability: 'net.wisp', + envVars: ['PUTER_TEST_WISP_SERVER'], + configOverrides: ([server]) => ({ wisp: { server } }), + }, + aiProvider('ai.openai', 'PUTER_TEST_AI_OPENAI_API_KEY', 'openai-completion'), + aiProvider('ai.claude', 'PUTER_TEST_AI_CLAUDE_API_KEY', 'claude'), + aiProvider('ai.gemini', 'PUTER_TEST_AI_GEMINI_API_KEY', 'gemini'), +]; + +const mergeOverrides = ( + base: ConfigOverrides, + extra: ConfigOverrides, +): ConfigOverrides => { + const out: ConfigOverrides = { ...base }; + for (const [key, value] of Object.entries(extra)) { + const existing = out[key]; + if ( + existing && + typeof existing === 'object' && + !Array.isArray(existing) && + value && + typeof value === 'object' && + !Array.isArray(value) + ) { + out[key] = mergeOverrides( + existing as ConfigOverrides, + value as ConfigOverrides, + ); + } else { + out[key] = value; + } + } + return out; +}; + +export type PuterJsTestOptions = { + capabilities: string[]; + configOverrides: ConfigOverrides; +}; + +/** + * Resolve capabilities and the matching server config for a suite run. + * The base config routes worker deploys to the local workerd so the + * workers suite runs on every platform — that's a dev dependency, not a + * capability. + */ +export const loadPuterJsTestOptions = ( + env: Record = process.env, +): PuterJsTestOptions => { + const capabilities: string[] = []; + let configOverrides: ConfigOverrides = { + workers: { localServer: 'true' }, + }; + + for (const mapping of MAPPINGS) { + const values = mapping.envVars.map((name) => env[name] ?? ''); + if (values.some((v) => v.length === 0)) continue; + capabilities.push(mapping.capability); + configOverrides = mergeOverrides( + configOverrides, + mapping.configOverrides(values), + ); + } + + return { capabilities, configOverrides }; +}; diff --git a/src/puter-js/tests/api/harness/coverage.ts b/src/puter-js/tests/api/harness/coverage.ts new file mode 100644 index 000000000..333b4fe93 --- /dev/null +++ b/src/puter-js/tests/api/harness/coverage.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * SDK coverage collection for the API suites. Enabled by `PUTER_COVERAGE=1` + * (the `test:puterjs:coverage` script), which expects the instrumented SDK + * bundle (`npm run build:coverage` in src/puter-js) — every first-party + * module then accumulates istanbul counters on `globalThis.__coverage__` + * in whatever runtime executes it. + * + * Each runner collects the counters from its runtime (vm contexts, the + * browser page, workerd responses) and writes one merged shard per + * platform; `tools/puterjsCoverageReport.mjs` merges the shards into the + * final reports in src/puter-js/coverage. + * + * Node-side only: imported by the runners, never bundled into suites. + */ + +export type IstanbulCoverage = Record; + +export const coverageEnabled = (): boolean => + Boolean(process.env.PUTER_COVERAGE); + +const SHARD_DIR = fileURLToPath( + new URL('../../../coverage/shards/', import.meta.url), +); + +/** Merge per-runtime coverage objects and write this platform's shard. */ +export const writeCoverageShard = async ( + platform: string, + coverages: Array, +): Promise => { + const present = coverages.filter( + (c): c is IstanbulCoverage => Boolean(c && Object.keys(c).length > 0), + ); + if (present.length === 0) { + throw new Error( + `no coverage collected on ${platform} — was the instrumented ` + + 'bundle built? (npm run build:workerLib:coverage)', + ); + } + const { createCoverageMap } = await import('istanbul-lib-coverage'); + const map = createCoverageMap({}); + for (const coverage of present) { + map.merge(coverage as Parameters[0]); + } + mkdirSync(SHARD_DIR, { recursive: true }); + writeFileSync( + `${SHARD_DIR}coverage-${platform}.json`, + JSON.stringify(map.toJSON()), + ); +}; diff --git a/src/puter-js/tests/api/harness/executor.ts b/src/puter-js/tests/api/harness/executor.ts index a2a66d61b..66d2655d7 100644 --- a/src/puter-js/tests/api/harness/executor.ts +++ b/src/puter-js/tests/api/harness/executor.ts @@ -5,8 +5,17 @@ import type { Platform, PuterSDK, RunTestResult, + SuiteTest, + SuiteTestSpec, } from './types.ts'; +const resolveSpec = ( + spec: SuiteTestSpec, +): { requires: string[]; platforms?: Platform[]; fn: SuiteTest } => + typeof spec === 'function' + ? { requires: [], fn: spec } + : { requires: spec.requires ?? [], platforms: spec.platforms, fn: spec.fn }; + export type RunTestArgs = { suiteName: string; testName: string; @@ -27,13 +36,14 @@ export const runTest = async ( puter: PuterSDK, ): Promise => { const suite = suites.find((s) => s.name === args.suiteName); - const test = suite?.tests[args.testName]; - if (!test) { + const spec = suite?.tests[args.testName]; + if (!spec) { return { ok: false, error: `unknown test "${args.suiteName} > ${args.testName}"`, }; } + const test = resolveSpec(spec).fn; try { await test({ @@ -65,11 +75,38 @@ export const runTest = async ( } }; +export type ListedTest = { + suiteName: string; + testName: string; + requires: string[]; + platforms?: Platform[]; +}; + /** Enumerate all tests — adapters use this to emit one `it()` per test. */ -export const listTests = (): Array<{ suiteName: string; testName: string }> => +export const listTests = (): ListedTest[] => suites.flatMap((s) => - Object.keys(s.tests).map((testName) => ({ - suiteName: s.name, - testName, - })), + Object.entries(s.tests).map(([testName, spec]) => { + const { requires, platforms } = resolveSpec(spec); + return { suiteName: s.name, testName, requires, platforms }; + }), ); + +/** + * Why a test can't run on this platform with these capabilities, or null + * if it can. Runners feed this into `it.skipIf` so constrained tests are + * visible as skips instead of silently missing. + */ +export const skipReason = ( + test: ListedTest, + platform: Platform, + capabilities: string[], +): string | null => { + if (test.platforms && !test.platforms.includes(platform)) { + return `runs only on: ${test.platforms.join(', ')}`; + } + const missing = test.requires.filter((r) => !capabilities.includes(r)); + if (missing.length > 0) { + return `missing capabilities: ${missing.join(', ')}`; + } + return null; +}; diff --git a/src/puter-js/tests/api/harness/nodeSdkLoader.ts b/src/puter-js/tests/api/harness/nodeSdkLoader.ts index 961675bb4..616568358 100644 --- a/src/puter-js/tests/api/harness/nodeSdkLoader.ts +++ b/src/puter-js/tests/api/harness/nodeSdkLoader.ts @@ -1,20 +1,25 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import vm from 'node:vm'; +import { coverageEnabled, type IstanbulCoverage } from './coverage.ts'; 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. +// readable stack traces when debugging suite failures. In coverage mode +// only `puter.js` is valid — that's the file the instrumented build +// writes (and the one inlined into the worker preamble). const BUNDLE_CANDIDATES = ['../../../dist/puter.cjs', '../../../dist/puter.js']; const DEV_BUNDLE = '../../../dist/puter.dev.js'; +const COVERAGE_BUNDLE = '../../../dist/puter.js'; const resolveBundle = (): string => { - const candidates = - process.env.PUTER_SDK_BUNDLE === 'dev' - ? [DEV_BUNDLE] - : BUNDLE_CANDIDATES; + const candidates = coverageEnabled() + ? [COVERAGE_BUNDLE] + : 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; @@ -24,6 +29,16 @@ const resolveBundle = (): string => { ); }; +// In coverage mode every vm context is retained so its `__coverage__` +// counters can be merged after the run (each test loads a fresh SDK). +const coverageContexts: Array> = []; + +/** Coverage counters from every SDK context created so far. */ +export const collectNodeCoverage = (): Array => + coverageContexts.map( + (context) => context.__coverage__ as IstanbulCoverage | undefined, + ); + // 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. @@ -63,6 +78,7 @@ export const loadNodePuter = (env: EnvManifest, token: string): PuterSDK => { delete context.localStorage; sdkScript().runInContext(vm.createContext(context)); + if (coverageEnabled()) coverageContexts.push(context); const puter = context.puter as PuterSDK; puter.setAuthToken(token); diff --git a/src/puter-js/tests/api/harness/types.ts b/src/puter-js/tests/api/harness/types.ts index 287030d5a..d3c6a2db3 100644 --- a/src/puter-js/tests/api/harness/types.ts +++ b/src/puter-js/tests/api/harness/types.ts @@ -23,7 +23,11 @@ export type EnvManifest = { users: { admin: TestUserCredentials; user: TestUserCredentials; + /** Second regular user, for cross-user permission tests. */ + other: TestUserCredentials; }; + /** Capability tags present in this env (see harness/capabilities.ts). */ + capabilities: string[]; }; export type Platform = 'node' | 'browser' | 'workerd'; @@ -42,18 +46,38 @@ export type TestContext = { export type SuiteTest = (t: TestContext) => void | Promise; +/** + * A suite entry is either a bare test function or a spec object that + * constrains where it runs. Tests whose constraints aren't met are + * reported as skipped by the runners, never silently dropped. + */ +export type SuiteTestSpec = + | SuiteTest + | { + /** Capability tags that must all be present (see capabilities.ts). */ + requires?: string[]; + /** Platforms this test can run on. Default: all. */ + platforms?: Platform[]; + fn: SuiteTest; + }; + /** Outcome of a single test run, serializable across runtime boundaries. */ export type RunTestResult = { ok: boolean; error?: string; + /** + * Istanbul counters from the executing runtime, present only when the + * instrumented SDK bundle is in play (see harness/coverage.ts). + */ + coverage?: Record; }; export type Suite = { name: string; - tests: Record; + tests: Record; }; export const suite = ( name: string, - tests: Record, + tests: Record, ): Suite => ({ name, tests }); diff --git a/src/puter-js/tests/api/harness/workerdEntry.ts b/src/puter-js/tests/api/harness/workerdEntry.ts index 48c07fc19..d22f0b75b 100644 --- a/src/puter-js/tests/api/harness/workerdEntry.ts +++ b/src/puter-js/tests/api/harness/workerdEntry.ts @@ -30,5 +30,13 @@ router.custom('POST', '/run', async (event: RouterEvent): Promise error: 'no per-request puter — was the puter-auth header sent?', }; } - return await runTest(args, puter); + const result = await runTest(args, puter); + // Present only when the instrumented SDK preamble is deployed. The + // isolate may recycle between requests, so counters ride along on + // every response and the runner merges them. + const coverage = (globalThis as Record).__coverage__; + if (coverage) { + result.coverage = coverage as Record; + } + return result; }); diff --git a/src/puter-js/tests/api/runners/browser.test.ts b/src/puter-js/tests/api/runners/browser.test.ts index f2d2d35ea..25fcdf7c7 100644 --- a/src/puter-js/tests/api/runners/browser.test.ts +++ b/src/puter-js/tests/api/runners/browser.test.ts @@ -4,12 +4,23 @@ import { setupPuterTestEnv, type PuterTestEnv, } from '@heyputer/backend/testUtil.ts'; +import type { IConfig } from '@heyputer/backend/types.ts'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts'; -import { listTests, type RunTestArgs } from '../harness/executor.ts'; +import { loadPuterJsTestOptions } from '../harness/capabilities.ts'; +import { + coverageEnabled, + writeCoverageShard, + type IstanbulCoverage, +} from '../harness/coverage.ts'; +import { listTests, skipReason, type RunTestArgs } from '../harness/executor.ts'; import type { EnvManifest, RunTestResult } from '../harness/types.ts'; const FIXTURE_PATH = '/__puterjs_suites__/fixture.html'; +const options = loadPuterJsTestOptions(); + // 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 @@ -22,11 +33,12 @@ describe('puter.js API suites (browser)', () => { let page: Page; beforeAll(async () => { - env = await setupPuterTestEnv(); + env = await setupPuterTestEnv(options.configOverrides as IConfig); manifest = { origin: env.origin, apiOrigin: env.apiOrigin, users: env.users, + capabilities: options.capabilities, }; const bundle = await bundleHarnessEntry( @@ -67,6 +79,23 @@ window.PUTER_ORIGIN = ${JSON.stringify(env.origin)}; await page.route(`**${FIXTURE_PATH}`, (route) => route.fulfill({ contentType: 'text/html', body: fixtureHtml }), ); + // In coverage mode the server would serve whichever bundle flavor + // it finds (puter.dev.js wins when present) — pin the page to the + // instrumented build instead. + if (coverageEnabled()) { + const instrumented = readFileSync( + fileURLToPath( + new URL('../../../dist/puter.js', import.meta.url), + ), + 'utf8', + ); + await page.route('**/puter.js/v2', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: instrumented, + }), + ); + } await page.goto(`${env.apiOrigin}${FIXTURE_PATH}`); await page.waitForFunction( () => @@ -80,15 +109,29 @@ window.PUTER_ORIGIN = ${JSON.stringify(env.origin)}; }, 120_000); afterAll(async () => { + if (coverageEnabled() && page) { + const coverage = await page.evaluate( + () => + (window as { __coverage__?: IstanbulCoverage }) + .__coverage__, + ); + await writeCoverageShard('browser', [coverage]); + } await browser?.close(); await env?.shutdown(); }); - for (const { suiteName, testName } of listTests()) { - it(`${suiteName} > ${testName}`, async () => { + for (const test of listTests()) { + const skip = skipReason(test, 'browser', options.capabilities); + it.skipIf(skip)(`${test.suiteName} > ${test.testName}`, async () => { const result = await page.evaluate( (args) => window.__runSuiteTest__(args), - { suiteName, testName, env: manifest, platform: 'browser' }, + { + suiteName: test.suiteName, + testName: test.testName, + env: manifest, + platform: 'browser', + }, ); expect(result.error ?? '').toBe(''); expect(result.ok).toBe(true); diff --git a/src/puter-js/tests/api/runners/node.test.ts b/src/puter-js/tests/api/runners/node.test.ts index 498f951c1..b07b2d143 100644 --- a/src/puter-js/tests/api/runners/node.test.ts +++ b/src/puter-js/tests/api/runners/node.test.ts @@ -3,10 +3,15 @@ import { setupPuterTestEnv, type PuterTestEnv, } from '@heyputer/backend/testUtil.ts'; -import { listTests, runTest } from '../harness/executor.ts'; -import { loadNodePuter } from '../harness/nodeSdkLoader.ts'; +import type { IConfig } from '@heyputer/backend/types.ts'; +import { loadPuterJsTestOptions } from '../harness/capabilities.ts'; +import { coverageEnabled, writeCoverageShard } from '../harness/coverage.ts'; +import { listTests, runTest, skipReason } from '../harness/executor.ts'; +import { collectNodeCoverage, loadNodePuter } from '../harness/nodeSdkLoader.ts'; import type { EnvManifest } from '../harness/types.ts'; +const options = loadPuterJsTestOptions(); + // 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. @@ -15,23 +20,33 @@ describe('puter.js API suites (node)', () => { let manifest: EnvManifest; beforeAll(async () => { - env = await setupPuterTestEnv(); + env = await setupPuterTestEnv(options.configOverrides as IConfig); manifest = { origin: env.origin, apiOrigin: env.apiOrigin, users: env.users, + capabilities: options.capabilities, }; }, 120_000); afterAll(async () => { + if (coverageEnabled()) { + await writeCoverageShard('node', collectNodeCoverage()); + } await env?.shutdown(); }); - for (const { suiteName, testName } of listTests()) { - it(`${suiteName} > ${testName}`, async () => { + for (const test of listTests()) { + const skip = skipReason(test, 'node', options.capabilities); + it.skipIf(skip)(`${test.suiteName} > ${test.testName}`, async () => { const puter = loadNodePuter(manifest, manifest.users.user.token); const result = await runTest( - { suiteName, testName, env: manifest, platform: 'node' }, + { + suiteName: test.suiteName, + testName: test.testName, + env: manifest, + platform: 'node', + }, puter, ); expect(result.error ?? '').toBe(''); diff --git a/src/puter-js/tests/api/runners/workerd.test.ts b/src/puter-js/tests/api/runners/workerd.test.ts index 1a01e9dd3..7fd10d72e 100644 --- a/src/puter-js/tests/api/runners/workerd.test.ts +++ b/src/puter-js/tests/api/runners/workerd.test.ts @@ -3,13 +3,26 @@ import { setupPuterTestEnv, type PuterTestEnv, } from '@heyputer/backend/testUtil.ts'; +import type { IConfig } from '@heyputer/backend/types.ts'; import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts'; -import { listTests, type RunTestArgs } from '../harness/executor.ts'; +import { loadPuterJsTestOptions } from '../harness/capabilities.ts'; +import { + coverageEnabled, + writeCoverageShard, + type IstanbulCoverage, +} from '../harness/coverage.ts'; +import { listTests, skipReason, 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'; +const options = loadPuterJsTestOptions(); + +// Counters ride along on every /run response (the workerd isolate can +// recycle mid-run); merged into one shard after the run. +const workerdCoverage: IstanbulCoverage[] = []; + // 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 @@ -21,15 +34,14 @@ describe('puter.js API suites (workerd)', () => { 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); + // `loadPuterJsTestOptions` routes worker deploys to the local + // workerd (anything truthy without ACCOUNTID does). + env = await setupPuterTestEnv(options.configOverrides as IConfig); manifest = { origin: env.origin, apiOrigin: env.apiOrigin, users: env.users, + capabilities: options.capabilities, }; const bundle = await bundleHarnessEntry( @@ -48,14 +60,18 @@ describe('puter.js API suites (workerd)', () => { }, 120_000); afterAll(async () => { + if (coverageEnabled()) { + await writeCoverageShard('workerd', workerdCoverage); + } await env?.shutdown(); }); - for (const { suiteName, testName } of listTests()) { - it(`${suiteName} > ${testName}`, async () => { + for (const test of listTests()) { + const skip = skipReason(test, 'workerd', options.capabilities); + it.skipIf(skip)(`${test.suiteName} > ${test.testName}`, async () => { const args: RunTestArgs = { - suiteName, - testName, + suiteName: test.suiteName, + testName: test.testName, env: manifest, platform: 'workerd', }; @@ -72,6 +88,9 @@ describe('puter.js API suites (workerd)', () => { throw new Error(`worker returned ${res.status}: ${text}`); } const result = JSON.parse(text) as RunTestResult; + if (result.coverage) { + workerdCoverage.push(result.coverage); + } expect(result.error ?? '').toBe(''); expect(result.ok).toBe(true); }); diff --git a/src/puter-js/tests/api/suites/ai.suite.ts b/src/puter-js/tests/api/suites/ai.suite.ts new file mode 100644 index 000000000..d2dab54d7 --- /dev/null +++ b/src/puter-js/tests/api/suites/ai.suite.ts @@ -0,0 +1,122 @@ +import { suite } from '../harness/types.ts'; + +/** + * These tests run keyless: the backend always registers the fake-chat + * provider (models `fake`, `costly`, `abuse`), so the whole SDK ↔ driver + * plumbing — including streaming — is testable without provider keys. + * Real-provider smoke tests are separate and capability-gated. + */ + +const textOf = (result: { + message?: { content?: unknown }; +}): string => { + const content = result?.message?.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((part) => + typeof part === 'string' ? part : ((part as { text?: string }).text ?? ''), + ) + .join(''); + } + return ''; +}; + +export default suite('ai', { + 'chat with the fake model returns a message': async (t) => { + const result = await t.puter.ai.chat('Hello there', { + model: 'fake', + }); + t.assert.ok(result.message, 'result should carry a message'); + t.assert.equal(result.message.role, 'assistant'); + t.assert.ok(textOf(result).length > 0, 'message should contain text'); + }, + + 'chat accepts a messages array': async (t) => { + const result = await t.puter.ai.chat( + [ + { role: 'system', content: 'You are a test fixture.' }, + { role: 'user', content: 'Say something.' }, + ], + { model: 'fake' }, + ); + t.assert.ok(textOf(result).length > 0, 'message should contain text'); + }, + + 'chat with stream true yields text parts': async (t) => { + const stream = await t.puter.ai.chat('Stream this', { + model: 'fake', + stream: true, + }); + let text = ''; + for await (const part of stream as AsyncIterable<{ text?: string }>) { + if (part?.text) text += part.text; + } + t.assert.ok(text.length > 0, 'streamed parts should contain text'); + }, + + 'chat with the costly model reports token usage': async (t) => { + const result = await t.puter.ai.chat( + 'Count the tokens of this prompt please', + { model: 'costly' }, + ); + const usage = result.usage as + | { input_tokens?: number; output_tokens?: number } + | undefined; + t.assert.ok(usage, 'result should carry usage'); + t.assert.ok( + (usage?.input_tokens ?? 0) > 0, + 'costly model should report input tokens', + ); + t.assert.ok( + (usage?.output_tokens ?? 0) > 0, + 'costly model should report output tokens', + ); + }, + + 'chat with an unknown model rejects': async (t) => { + await t.assert.rejects( + () => + t.puter.ai.chat('Hello', { + model: 'ai-suite-no-such-model', + }), + 'unknown model should reject', + ); + }, + + 'listModels hides the internal test models': async (t) => { + // The public models endpoint deliberately filters fake/costly/abuse. + const models = await t.puter.ai.listModels(); + t.assert.ok(Array.isArray(models), 'listModels should return an array'); + const ids = models.map((m: { id?: string }) => m.id); + for (const hidden of ['fake', 'costly', 'abuse']) { + t.assert.ok( + !ids.includes(hidden), + `public model list should not expose "${hidden}"`, + ); + } + }, + + 'the models driver method reports the fake models': async (t) => { + const resp = await t.puter.drivers.call( + 'puter-chat-completion', + 'ai-chat', + 'models', + {}, + ); + const ids = JSON.stringify(resp.result ?? resp); + t.assert.ok( + ids.includes('fake'), + 'driver-level model list should include the fake model', + ); + }, + + 'listModelProviders returns an array without fake-chat': async (t) => { + const providers = await t.puter.ai.listModelProviders(); + t.assert.ok(Array.isArray(providers), 'should return an array'); + t.assert.ok( + !providers.includes('fake-chat'), + 'the internal fake-chat provider should stay hidden', + ); + }, +}); diff --git a/src/puter-js/tests/api/suites/apps.suite.ts b/src/puter-js/tests/api/suites/apps.suite.ts index 32a60b9a1..a7e64fdd8 100644 --- a/src/puter-js/tests/api/suites/apps.suite.ts +++ b/src/puter-js/tests/api/suites/apps.suite.ts @@ -11,6 +11,43 @@ export default suite('apps', { t.assert.equal(fetched.index_url, 'https://example.com/create'); }, + 'create with an options object stores app metadata': async (t) => { + const app = await t.puter.apps.create({ + name: 'apps-suite-meta', + indexURL: 'https://example.com/meta', + title: 'Metadata App', + description: 'An app with rich metadata', + maximizeOnStart: true, + }); + t.assert.equal(app.title, 'Metadata App'); + const fetched = await t.puter.apps.get('apps-suite-meta'); + t.assert.equal(fetched.description, 'An app with rich metadata'); + t.assert.equal(Boolean(fetched.maximize_on_start), true); + }, + + 'create without an index URL rejects': async (t) => { + await t.assert.rejects( + () => + t.puter.apps.create({ name: 'apps-suite-no-url' } as never), + 'create without indexURL should reject', + ); + }, + + 'create with a duplicate name rejects': async (t) => { + await t.puter.apps.create( + 'apps-suite-dup', + 'https://example.com/dup', + ); + await t.assert.rejects( + () => + t.puter.apps.create( + 'apps-suite-dup', + 'https://example.com/dup-2', + ), + 'duplicate app name should reject', + ); + }, + 'list includes apps the user created': async (t) => { await t.puter.apps.create( 'apps-suite-list', @@ -34,6 +71,27 @@ export default suite('apps', { t.assert.equal(updated.index_url, 'https://example.com/after'); }, + 'update changes title and description': async (t) => { + await t.puter.apps.create( + 'apps-suite-update-meta', + 'https://example.com/update-meta', + ); + const updated = await t.puter.apps.update('apps-suite-update-meta', { + indexURL: 'https://example.com/update-meta', + title: 'New Title', + description: 'New description', + }); + t.assert.equal(updated.title, 'New Title'); + t.assert.equal(updated.description, 'New description'); + }, + + 'get of an unknown app rejects': async (t) => { + await t.assert.rejects( + () => t.puter.apps.get('apps-suite-does-not-exist'), + 'get of an unknown app should reject', + ); + }, + 'delete removes the app': async (t) => { await t.puter.apps.create( 'apps-suite-delete', @@ -45,4 +103,27 @@ export default suite('apps', { 'get of a deleted app should reject', ); }, + + 'checkName distinguishes taken from available names': async (t) => { + await t.puter.apps.create( + 'apps-suite-taken', + 'https://example.com/taken', + ); + const taken = await t.puter.apps.checkName('apps-suite-taken'); + const available = await t.puter.apps.checkName( + 'apps-suite-surely-available', + ); + t.assert.ok( + JSON.stringify(taken) !== JSON.stringify(available), + 'taken and available names should report differently', + ); + }, + + 'getDeveloperProfile returns a response': async (t) => { + const profile = await t.puter.apps.getDeveloperProfile(); + t.assert.ok( + profile && typeof profile === 'object', + 'developer profile should be an object', + ); + }, }); diff --git a/src/puter-js/tests/api/suites/auth.suite.ts b/src/puter-js/tests/api/suites/auth.suite.ts index 76a6b9baa..82d32a38c 100644 --- a/src/puter-js/tests/api/suites/auth.suite.ts +++ b/src/puter-js/tests/api/suites/auth.suite.ts @@ -6,10 +6,43 @@ export default suite('auth', { t.assert.equal(user.username, t.env.users.user.username); }, + 'whoami matches getUser': async (t) => { + const whoami = await t.puter.auth.whoami(); + const user = await t.puter.auth.getUser(); + t.assert.equal(whoami.username, user.username); + t.assert.equal(whoami.uuid, user.uuid); + }, + 'isSignedIn reports true with a valid token': async (t) => { t.assert.equal(t.puter.auth.isSignedIn(), true); }, + 'signOut clears the session client-side': { + // The SDK refuses signOut inside (service) workers. + platforms: ['node', 'browser'], + fn: async (t) => { + // The browser platform shares one SDK instance across tests, so + // always restore the token before finishing. + try { + t.puter.auth.signOut(); + t.assert.equal(t.puter.auth.isSignedIn(), false); + } finally { + t.puter.setAuthToken(t.env.users.user.token); + } + t.assert.equal(t.puter.auth.isSignedIn(), true); + }, + }, + + 'a bogus token is rejected by the API': async (t) => { + const res = await fetch(`${t.env.apiOrigin}/whoami`, { + headers: { + Authorization: 'Bearer not-a-real-token', + Origin: t.env.apiOrigin, + }, + }); + t.assert.equal(res.status, 401); + }, + 'password login issues a working token': async (t) => { const res = await fetch(`${t.env.origin}/login`, { method: 'POST', @@ -27,6 +60,29 @@ export default suite('auth', { t.assert.ok(body.token, 'login response should include a token'); }, + 'login with a wrong password fails': 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: 'definitely-not-the-password', + }), + }); + t.assert.ok(res.status !== 200, 'wrong password should not yield 200'); + }, + + 'getMonthlyUsage returns a usage report': async (t) => { + const usage = await t.puter.auth.getMonthlyUsage(); + t.assert.ok( + usage && typeof usage === 'object', + 'usage report should be an object', + ); + }, + 'regular user is rejected by admin-gated endpoints': async (t) => { const asUser = await fetch(`${t.env.apiOrigin}/serverInfo`, { headers: { diff --git a/src/puter-js/tests/api/suites/fs.suite.ts b/src/puter-js/tests/api/suites/fs.suite.ts index de41b62a7..3a9a6db57 100644 --- a/src/puter-js/tests/api/suites/fs.suite.ts +++ b/src/puter-js/tests/api/suites/fs.suite.ts @@ -1,7 +1,7 @@ import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; -const home = (t: { env: { users: { user: { username: string } } } }) => - `/${t.env.users.user.username}`; +const home = (t: TestContext) => `/${t.env.users.user.username}`; export default suite('fs', { 'write creates a file and read returns its content': async (t) => { @@ -11,6 +11,80 @@ export default suite('fs', { t.assert.equal(await blob.text(), 'hello from the suite'); }, + 'write round-trips binary data intact': async (t) => { + const path = `${home(t)}/fs-suite-binary.bin`; + const bytes = new Uint8Array(256); + for (let i = 0; i < bytes.length; i++) bytes[i] = i; + await t.puter.fs.write(path, bytes); + const blob = await t.puter.fs.read(path); + const roundTripped = new Uint8Array(await blob.arrayBuffer()); + t.assert.equal(roundTripped.length, bytes.length); + for (let i = 0; i < bytes.length; i++) { + if (roundTripped[i] !== bytes[i]) { + t.assert.equal(roundTripped[i], bytes[i], `byte ${i} differs`); + } + } + }, + + 'write overwrites an existing file by default': async (t) => { + const path = `${home(t)}/fs-suite-overwrite.txt`; + await t.puter.fs.write(path, 'first'); + await t.puter.fs.write(path, 'second'); + const blob = await t.puter.fs.read(path); + t.assert.equal(await blob.text(), 'second'); + }, + + 'write with dedupeName creates a sibling instead of overwriting': async (t) => { + const path = `${home(t)}/fs-suite-dedupe.txt`; + const first = await t.puter.fs.write(path, 'original'); + const second = await t.puter.fs.write(path, 'copy', { + overwrite: false, + dedupeName: true, + }); + t.assert.ok(first.name !== second.name, 'dedupe should pick a new name'); + const blob = await t.puter.fs.read(path); + t.assert.equal(await blob.text(), 'original'); + }, + + 'write with createMissingParents builds the directory tree': async (t) => { + const path = `${home(t)}/fs-suite-deep/a/b/file.txt`; + await t.puter.fs.write(path, 'nested', { createMissingParents: true }); + const blob = await t.puter.fs.read(path); + t.assert.equal(await blob.text(), 'nested'); + const dir = await t.puter.fs.stat(`${home(t)}/fs-suite-deep/a/b`); + t.assert.equal(Boolean(dir.is_dir), true); + }, + + 'write handles unicode and spaces in names': async (t) => { + const path = `${home(t)}/fs suite ünïcödé 文件.txt`; + await t.puter.fs.write(path, 'unicode content'); + const info = await t.puter.fs.stat(path); + t.assert.equal(info.name, 'fs suite ünïcödé 文件.txt'); + const blob = await t.puter.fs.read(path); + t.assert.equal(await blob.text(), 'unicode content'); + }, + + 'write a ~1MB file round-trips': async (t) => { + const path = `${home(t)}/fs-suite-large.bin`; + const bytes = new Uint8Array(1024 * 1024); + for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; + await t.puter.fs.write(path, bytes); + const blob = await t.puter.fs.read(path); + const got = new Uint8Array(await blob.arrayBuffer()); + t.assert.equal(got.length, bytes.length); + // spot-check a few offsets rather than 1M assertions + for (const i of [0, 1, 4093, 524287, bytes.length - 1]) { + t.assert.equal(got[i], bytes[i], `byte ${i} differs`); + } + }, + + 'read of a missing file rejects': async (t) => { + await t.assert.rejects( + () => t.puter.fs.read(`${home(t)}/fs-suite-no-such-file.txt`), + 'read of a missing file should reject', + ); + }, + 'stat reports name and type': async (t) => { const path = `${home(t)}/fs-suite-stat.txt`; await t.puter.fs.write(path, 'stat me'); @@ -19,6 +93,22 @@ export default suite('fs', { t.assert.equal(Boolean(info.is_dir), false); }, + 'stat of a missing path rejects': async (t) => { + await t.assert.rejects( + () => t.puter.fs.stat(`${home(t)}/fs-suite-no-such-stat.txt`), + 'stat of a missing path should reject', + ); + }, + + 'stat returnSize reports directory size': async (t) => { + const dir = `${home(t)}/fs-suite-sized-dir`; + await t.puter.fs.mkdir(dir); + await t.puter.fs.write(`${dir}/data.txt`, 'twelve bytes'); + const info = await t.puter.fs.stat(dir, { returnSize: true }); + t.assert.equal(typeof info.size, 'number'); + t.assert.ok(info.size >= 12, `dir size ${info.size} should be >= 12`); + }, + 'mkdir creates a directory listable via readdir': async (t) => { const dir = `${home(t)}/fs-suite-dir`; await t.puter.fs.mkdir(dir); @@ -28,6 +118,100 @@ export default suite('fs', { t.assert.equal(entries[0].name, 'inside.txt'); }, + 'mkdir with createMissingParents creates nested dirs': async (t) => { + const dir = `${home(t)}/fs-suite-mk/x/y/z`; + await t.puter.fs.mkdir(dir, { createMissingParents: true }); + const info = await t.puter.fs.stat(dir); + t.assert.equal(Boolean(info.is_dir), true); + }, + + 'mkdir over an existing directory is idempotent': async (t) => { + const dir = `${home(t)}/fs-suite-mk-dup`; + const first = await t.puter.fs.mkdir(dir); + const second = await t.puter.fs.mkdir(dir); + t.assert.equal(second.uid, first.uid, 'should return the same dir'); + }, + + 'mkdir over an existing file rejects': async (t) => { + const path = `${home(t)}/fs-suite-mk-over-file`; + await t.puter.fs.write(path, 'occupied'); + await t.assert.rejects( + () => t.puter.fs.mkdir(path), + 'mkdir over an existing file should reject', + ); + }, + + 'mkdir with dedupeName creates a renamed sibling': async (t) => { + const dir = `${home(t)}/fs-suite-mk-dedupe`; + const first = await t.puter.fs.mkdir(dir); + const second = await t.puter.fs.mkdir(dir, { dedupeName: true }); + t.assert.ok( + first.name !== second.name, + 'dedupeName should pick a new directory name', + ); + }, + + 'readdir of a missing directory rejects': async (t) => { + await t.assert.rejects( + () => t.puter.fs.readdir(`${home(t)}/fs-suite-no-such-dir`), + 'readdir of a missing dir should reject', + ); + }, + + 'copy duplicates a file': async (t) => { + const src = `${home(t)}/fs-suite-copy-src.txt`; + const dstDir = `${home(t)}/fs-suite-copy-dst`; + await t.puter.fs.write(src, 'copy me'); + await t.puter.fs.mkdir(dstDir); + await t.puter.fs.copy(src, dstDir); + const blob = await t.puter.fs.read(`${dstDir}/fs-suite-copy-src.txt`); + t.assert.equal(await blob.text(), 'copy me'); + // source still exists + t.assert.ok(await t.puter.fs.stat(src)); + }, + + 'copy with newName renames the duplicate': async (t) => { + const src = `${home(t)}/fs-suite-copy-rename-src.txt`; + const dstDir = `${home(t)}/fs-suite-copy-rename-dst`; + await t.puter.fs.write(src, 'renamed copy'); + await t.puter.fs.mkdir(dstDir); + await t.puter.fs.copy(src, dstDir, { newName: 'renamed.txt' }); + const blob = await t.puter.fs.read(`${dstDir}/renamed.txt`); + t.assert.equal(await blob.text(), 'renamed copy'); + }, + + 'move relocates a file': async (t) => { + const src = `${home(t)}/fs-suite-move-src.txt`; + const dstDir = `${home(t)}/fs-suite-move-dst`; + await t.puter.fs.write(src, 'move me'); + await t.puter.fs.mkdir(dstDir); + await t.puter.fs.move(src, dstDir); + const blob = await t.puter.fs.read(`${dstDir}/fs-suite-move-src.txt`); + t.assert.equal(await blob.text(), 'move me'); + await t.assert.rejects( + () => t.puter.fs.stat(src), + 'moved-away source should no longer stat', + ); + }, + + 'move to a full destination path renames the file': async (t) => { + const src = `${home(t)}/fs-suite-move-rename-src.txt`; + const dst = `${home(t)}/fs-suite-move-renamed.txt`; + await t.puter.fs.write(src, 'move+rename'); + await t.puter.fs.move(src, dst); + const blob = await t.puter.fs.read(dst); + t.assert.equal(await blob.text(), 'move+rename'); + }, + + 'rename changes the file name in place': async (t) => { + const path = `${home(t)}/fs-suite-rename-before.txt`; + await t.puter.fs.write(path, 'rename me'); + const renamed = await t.puter.fs.rename(path, 'fs-suite-rename-after.txt'); + t.assert.equal(renamed.name, 'fs-suite-rename-after.txt'); + const blob = await t.puter.fs.read(`${home(t)}/fs-suite-rename-after.txt`); + t.assert.equal(await blob.text(), 'rename me'); + }, + 'delete removes a file': async (t) => { const path = `${home(t)}/fs-suite-delete.txt`; await t.puter.fs.write(path, 'ephemeral'); @@ -38,10 +222,126 @@ export default suite('fs', { ); }, + 'delete recursive removes a directory tree': async (t) => { + const dir = `${home(t)}/fs-suite-delete-tree`; + await t.puter.fs.mkdir(dir); + await t.puter.fs.write(`${dir}/a.txt`, 'a'); + await t.puter.fs.write(`${dir}/b.txt`, 'b'); + await t.puter.fs.delete(dir, { recursive: true }); + await t.assert.rejects( + () => t.puter.fs.stat(dir), + 'deleted tree should no longer stat', + ); + }, + + 'delete descendantsOnly empties a directory but keeps it': async (t) => { + const dir = `${home(t)}/fs-suite-empty-me`; + await t.puter.fs.mkdir(dir); + await t.puter.fs.write(`${dir}/inside.txt`, 'x'); + await t.puter.fs.delete(dir, { + recursive: true, + descendantsOnly: true, + }); + const entries = await t.puter.fs.readdir(dir); + t.assert.equal(entries.length, 0); + }, + + 'delete accepts multiple paths': async (t) => { + const a = `${home(t)}/fs-suite-multi-a.txt`; + const b = `${home(t)}/fs-suite-multi-b.txt`; + await t.puter.fs.write(a, 'a'); + await t.puter.fs.write(b, 'b'); + // Object form: delete's positional form treats a leading array as + // the options object, so `delete([a, b])` never reaches the server. + await t.puter.fs.delete({ paths: [a, b] }); + await t.assert.rejects(() => t.puter.fs.stat(a)); + await t.assert.rejects(() => t.puter.fs.stat(b)); + }, + + 'space reports capacity and usage': async (t) => { + const df = await t.puter.fs.space(); + t.assert.equal(typeof Number(df.capacity), 'number'); + t.assert.ok(Number(df.capacity) > 0, 'capacity should be positive'); + t.assert.ok(Number(df.used) >= 0, 'used should be non-negative'); + }, + + 'sign returns signed entries for a file': async (t) => { + const path = `${home(t)}/fs-suite-sign.txt`; + await t.puter.fs.write(path, 'sign me'); + const info = await t.puter.fs.stat(path); + const signed = await t.puter.fs.sign(undefined, { + uid: info.uid, + action: 'read', + }); + const item = signed.items ?? signed; + t.assert.ok(item, 'sign should return a result'); + }, + + 'getReadURL grants unauthenticated read access': async (t) => { + const path = `${home(t)}/fs-suite-readurl.txt`; + await t.puter.fs.write(path, 'public via token'); + const url = await t.puter.fs.getReadURL(path); + t.assert.ok(url.includes('/token-read'), 'should be a token-read URL'); + // No Authorization header — the token in the URL is the only auth. + const resp = await fetch(url); + t.assert.equal(resp.status, 200); + t.assert.equal(await resp.text(), 'public via token'); + }, + + 'getReadURL of a directory rejects': async (t) => { + const dir = `${home(t)}/fs-suite-readurl-dir`; + await t.puter.fs.mkdir(dir); + await t.assert.rejects( + () => t.puter.fs.getReadURL(dir), + 'getReadURL of a directory should reject', + ); + }, + + 'batch send applies move and delete operations': async (t) => { + const dir = `${home(t)}/fs-suite-batch`; + await t.puter.fs.mkdir(dir); + await t.puter.fs.write(`${dir}/moved.txt`, 'batch move'); + await t.puter.fs.write(`${dir}/removed.txt`, 'batch delete'); + const batch = new t.puter.fs.Batch(); + batch.move(`${dir}/moved.txt`, dir, 'moved-renamed.txt'); + batch.delete(`${dir}/removed.txt`); + const results = await batch.send(); + t.assert.ok(Array.isArray(results), 'batch should return results'); + const blob = await t.puter.fs.read(`${dir}/moved-renamed.txt`); + t.assert.equal(await blob.text(), 'batch move'); + await t.assert.rejects(() => t.puter.fs.stat(`${dir}/removed.txt`)); + }, + + 'upload stores multiple files into a directory': async (t) => { + const dir = `${home(t)}/fs-suite-upload`; + await t.puter.fs.mkdir(dir); + const files = [ + new File(['upload one'], 'up-1.txt', { type: 'text/plain' }), + new File(['upload two'], 'up-2.txt', { type: 'text/plain' }), + ]; + await t.puter.fs.upload(files, dir); + const entries = await t.puter.fs.readdir(dir); + const names = entries.map((e: { name: string }) => e.name).sort(); + t.assert.deepEqual(names, ['up-1.txt', 'up-2.txt']); + const blob = await t.puter.fs.read(`${dir}/up-2.txt`); + t.assert.equal(await blob.text(), 'upload two'); + }, + '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", ); }, + + 'users cannot write outside their home': async (t) => { + await t.assert.rejects( + () => + t.puter.fs.write( + `/${t.env.users.other.username}/fs-suite-intrusion.txt`, + 'should not exist', + ), + "writing into another user's home should reject", + ); + }, }); diff --git a/src/puter-js/tests/api/suites/hosting.suite.ts b/src/puter-js/tests/api/suites/hosting.suite.ts new file mode 100644 index 000000000..d78044f76 --- /dev/null +++ b/src/puter-js/tests/api/suites/hosting.suite.ts @@ -0,0 +1,110 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +const home = (t: TestContext) => `/${t.env.users.user.username}`; + +/** + * Sites are served on `.` — with the + * default test config that's `.site.puter.localhost`, which + * resolves to loopback like every other *.localhost name. + * + * The subdomain driver requires `root_dir` to be an existing directory, + * so every test creates one first. + */ +const siteUrl = (t: TestContext, subdomain: string) => { + const port = new URL(t.env.apiOrigin).port; + return `http://${subdomain}.site.puter.localhost:${port}/`; +}; + +const makeSiteDir = async (t: TestContext, name: string, html?: string) => { + const dir = `${home(t)}/hosting-suite-${name}`; + await t.puter.fs.mkdir(dir); + if (html) await t.puter.fs.write(`${dir}/index.html`, html); + return dir; +}; + +export default suite('hosting', { + 'create registers a subdomain retrievable by get': async (t) => { + const dir = await makeSiteDir(t, 'create'); + const created = await t.puter.hosting.create('hosting-suite-create', dir); + t.assert.equal(created.subdomain, 'hosting-suite-create'); + const fetched = await t.puter.hosting.get('hosting-suite-create'); + t.assert.equal(fetched.subdomain, 'hosting-suite-create'); + }, + + 'create without an existing root dir rejects': async (t) => { + await t.assert.rejects( + () => + t.puter.hosting.create( + 'hosting-suite-no-dir', + `${home(t)}/hosting-suite-does-not-exist`, + ), + 'create pointing at a missing directory should reject', + ); + }, + + 'create with a duplicate subdomain rejects': async (t) => { + const dir = await makeSiteDir(t, 'dup'); + await t.puter.hosting.create('hosting-suite-dup', dir); + await t.assert.rejects( + () => t.puter.hosting.create('hosting-suite-dup', dir), + 'duplicate subdomain should reject', + ); + }, + + 'list includes created subdomains': async (t) => { + const dir = await makeSiteDir(t, 'listed'); + await t.puter.hosting.create('hosting-suite-listed', dir); + const sites = await t.puter.hosting.list(); + t.assert.ok( + sites.some( + (s: { subdomain: string }) => + s.subdomain === 'hosting-suite-listed', + ), + 'created subdomain should appear in list', + ); + }, + + 'a subdomain serves its root directory': async (t) => { + const dir = await makeSiteDir( + t, + 'served', + '

hosting suite index

', + ); + await t.puter.hosting.create('hosting-suite-served', dir); + + const res = await fetch(siteUrl(t, 'hosting-suite-served')); + t.assert.equal(res.status, 200); + const body = await res.text(); + t.assert.ok( + body.includes('hosting suite index'), + 'served page should contain the index content', + ); + }, + + 'update repoints the subdomain to a new directory': async (t) => { + const dirA = await makeSiteDir(t, 'dir-a', 'site A'); + const dirB = await makeSiteDir(t, 'dir-b', 'site B'); + + await t.puter.hosting.create('hosting-suite-repoint', dirA); + const before = await fetch(siteUrl(t, 'hosting-suite-repoint')); + t.assert.ok((await before.text()).includes('site A')); + + await t.puter.hosting.update('hosting-suite-repoint', dirB); + const after = await fetch(siteUrl(t, 'hosting-suite-repoint')); + t.assert.ok( + (await after.text()).includes('site B'), + 'updated subdomain should serve the new directory', + ); + }, + + 'delete removes the subdomain': async (t) => { + const dir = await makeSiteDir(t, 'delete'); + await t.puter.hosting.create('hosting-suite-delete', dir); + await t.puter.hosting.delete('hosting-suite-delete'); + await t.assert.rejects( + () => t.puter.hosting.get('hosting-suite-delete'), + 'get of a deleted subdomain should reject', + ); + }, +}); diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index 145d8f672..9760e0e86 100644 --- a/src/puter-js/tests/api/suites/index.ts +++ b/src/puter-js/tests/api/suites/index.ts @@ -1,11 +1,28 @@ import type { Suite } from '../harness/types.ts'; +import ai from './ai.suite.ts'; import apps from './apps.suite.ts'; import auth from './auth.suite.ts'; import fs from './fs.suite.ts'; +import hosting from './hosting.suite.ts'; 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 workers from './workers.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]; +export const suites: Suite[] = [ + ai, + apps, + auth, + fs, + hosting, + kv, + net, + perms, + system, + workers, +]; diff --git a/src/puter-js/tests/api/suites/kv.suite.ts b/src/puter-js/tests/api/suites/kv.suite.ts index ecc110835..e2a8d6540 100644 --- a/src/puter-js/tests/api/suites/kv.suite.ts +++ b/src/puter-js/tests/api/suites/kv.suite.ts @@ -13,18 +13,123 @@ export default suite('kv', { }); }, + 'set overwrites an existing value': async (t) => { + await t.puter.kv.set('kv-suite-overwrite', 'first'); + await t.puter.kv.set('kv-suite-overwrite', 'second'); + t.assert.equal(await t.puter.kv.get('kv-suite-overwrite'), 'second'); + }, + 'get of a missing key returns null': async (t) => { t.assert.equal(await t.puter.kv.get('kv-suite-missing'), null); }, + 'batch set stores every item': async (t) => { + await t.puter.kv.set([ + { key: 'kv-suite-batch-1', value: 'one' }, + { key: 'kv-suite-batch-2', value: 'two' }, + { key: 'kv-suite-batch-3', value: { three: 3 } }, + ]); + t.assert.equal(await t.puter.kv.get('kv-suite-batch-1'), 'one'); + t.assert.equal(await t.puter.kv.get('kv-suite-batch-2'), 'two'); + t.assert.deepEqual(await t.puter.kv.get('kv-suite-batch-3'), { + three: 3, + }); + }, + '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); }, + 'del of a missing key still resolves': async (t) => { + const res = await t.puter.kv.del('kv-suite-del-missing'); + t.assert.ok(res, 'del of a missing key should resolve truthy'); + }, + '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); }, + + 'incr by an amount adds that amount': async (t) => { + await t.puter.kv.incr('kv-suite-incr-amount', 5); + t.assert.equal(await t.puter.kv.incr('kv-suite-incr-amount', 10), 15); + }, + + 'decr counts down': async (t) => { + await t.puter.kv.incr('kv-suite-decr', 10); + t.assert.equal(await t.puter.kv.decr('kv-suite-decr'), 9); + }, + + 'update patches paths inside an object value': async (t) => { + await t.puter.kv.set('kv-suite-update', { + profile: { color: 'red', size: 'm' }, + }); + await t.puter.kv.update('kv-suite-update', { 'profile.color': 'blue' }); + const value = await t.puter.kv.get('kv-suite-update'); + t.assert.equal(value.profile.color, 'blue'); + t.assert.equal(value.profile.size, 'm'); + }, + + 'remove deletes paths inside an object value': async (t) => { + await t.puter.kv.set('kv-suite-remove', { keep: 1, drop: 2 }); + await t.puter.kv.remove('kv-suite-remove', 'drop'); + const value = await t.puter.kv.get('kv-suite-remove'); + t.assert.equal(value.keep, 1); + t.assert.equal(value.drop, undefined); + }, + + 'expireAt in the past makes the key unreadable': async (t) => { + await t.puter.kv.set('kv-suite-expired', 'stale'); + await t.puter.kv.expireAt( + 'kv-suite-expired', + Math.floor(Date.now() / 1000) - 60, + ); + t.assert.equal(await t.puter.kv.get('kv-suite-expired'), null); + }, + + 'expire with a future ttl keeps the key readable': async (t) => { + await t.puter.kv.set('kv-suite-expire-future', 'fresh'); + await t.puter.kv.expire('kv-suite-expire-future', 3600); + t.assert.equal(await t.puter.kv.get('kv-suite-expire-future'), 'fresh'); + }, + + 'list returns keys matching a prefix pattern': async (t) => { + await t.puter.kv.set('kv-suite-list-a', 1); + await t.puter.kv.set('kv-suite-list-b', 2); + await t.puter.kv.set('kv-suite-unrelated', 3); + const keys = await t.puter.kv.list('kv-suite-list-*'); + t.assert.deepEqual( + [...keys].sort(), + ['kv-suite-list-a', 'kv-suite-list-b'], + ); + }, + + 'list with returnValues returns key-value pairs': async (t) => { + await t.puter.kv.set('kv-suite-pairs-x', 'val-x'); + const pairs = await t.puter.kv.list('kv-suite-pairs-*', true); + t.assert.equal(pairs.length, 1); + t.assert.equal(pairs[0].key, 'kv-suite-pairs-x'); + t.assert.equal(pairs[0].value, 'val-x'); + }, + + 'set rejects an oversized key client-side': async (t) => { + const bigKey = 'k'.repeat(1025); + await t.assert.rejects( + () => t.puter.kv.set(bigKey, 'x'), + 'a >1KB key should be rejected', + ); + }, + + 'set rejects an undefined key client-side': async (t) => { + await t.assert.rejects( + () => + (t.puter.kv.set as (k: unknown, v: unknown) => Promise)( + undefined, + 'x', + ), + 'an undefined key should be rejected', + ); + }, }); diff --git a/src/puter-js/tests/api/suites/net.suite.ts b/src/puter-js/tests/api/suites/net.suite.ts new file mode 100644 index 000000000..b8f981b48 --- /dev/null +++ b/src/puter-js/tests/api/suites/net.suite.ts @@ -0,0 +1,82 @@ +import { suite } from '../harness/types.ts'; + +/** + * The relay-token endpoints are part of the core backend and run keyless; + * only the relay itself (`wisp.server`) is external. Socket-level tests + * are capability-gated on `net.wisp`. + */ +export default suite('net', { + 'relay-token create mints a token': async (t) => { + const res = await fetch( + `${t.env.apiOrigin}/wisp/relay-token/create`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${t.env.users.user.token}`, + 'Content-Type': 'application/json', + Origin: t.env.apiOrigin, + }, + body: JSON.stringify({}), + }, + ); + t.assert.equal(res.status, 200); + const body = (await res.json()) as { token?: string }; + t.assert.ok(body.token, 'response should include a token'); + t.assert.ok('server' in body, 'response should include the server field'); + }, + + 'relay-token verify accepts a freshly minted token': async (t) => { + const createRes = await fetch( + `${t.env.apiOrigin}/wisp/relay-token/create`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${t.env.users.user.token}`, + 'Content-Type': 'application/json', + Origin: t.env.apiOrigin, + }, + body: JSON.stringify({}), + }, + ); + const { token } = (await createRes.json()) as { token: string }; + + const verifyRes = await fetch( + `${t.env.apiOrigin}/wisp/relay-token/verify`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: t.env.apiOrigin, + }, + body: JSON.stringify({ token }), + }, + ); + t.assert.equal(verifyRes.status, 200); + }, + + 'relay-token verify rejects garbage': async (t) => { + const res = await fetch( + `${t.env.apiOrigin}/wisp/relay-token/verify`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: t.env.apiOrigin, + }, + body: JSON.stringify({ token: 'not-a-real-wisp-token' }), + }, + ); + t.assert.ok(res.status !== 200, 'garbage token should not verify'); + }, + + 'generateWispV1URL embeds a relay token': { + requires: ['net.wisp'], + fn: async (t) => { + const url = await t.puter.net.generateWispV1URL(); + t.assert.ok( + url.startsWith('ws'), + `wisp URL should point at the configured relay, got: ${url}`, + ); + }, + }, +}); diff --git a/src/puter-js/tests/api/suites/perms.suite.ts b/src/puter-js/tests/api/suites/perms.suite.ts new file mode 100644 index 000000000..fcb713c6b --- /dev/null +++ b/src/puter-js/tests/api/suites/perms.suite.ts @@ -0,0 +1,144 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +const home = (t: TestContext) => `/${t.env.users.user.username}`; + +/** Read a file as the `other` user via plain fetch — works on every platform. */ +const readAsOther = (t: TestContext, path: string) => + fetch( + `${t.env.apiOrigin}/read?${new URLSearchParams({ file: path })}`, + { + headers: { + Authorization: `Bearer ${t.env.users.other.token}`, + Origin: t.env.apiOrigin, + }, + }, + ); + +export default suite('perms', { + 'grantUser lets another user read a file': async (t) => { + const path = `${home(t)}/perms-suite-shared.txt`; + await t.puter.fs.write(path, 'shared content'); + + const before = await readAsOther(t, path); + t.assert.ok( + before.status !== 200, + `other user should not read before grant (got ${before.status})`, + ); + + const granted = await t.puter.perms.grantUser( + t.env.users.other.username, + `fs:${path}:read`, + ); + t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); + + const after = await readAsOther(t, path); + t.assert.equal(after.status, 200); + t.assert.equal(await after.text(), 'shared content'); + }, + + 'revokeUser takes a granted permission away': async (t) => { + const path = `${home(t)}/perms-suite-revoked.txt`; + await t.puter.fs.write(path, 'soon private again'); + const permission = `fs:${path}:read`; + + await t.puter.perms.grantUser(t.env.users.other.username, permission); + const whileGranted = await readAsOther(t, path); + t.assert.equal(whileGranted.status, 200); + + const revoked = await t.puter.perms.revokeUser( + t.env.users.other.username, + permission, + ); + t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); + + const afterRevoke = await readAsOther(t, path); + t.assert.ok( + afterRevoke.status !== 200, + `read should fail after revoke (got ${afterRevoke.status})`, + ); + }, + + 'grantUser to an unknown user reports an error': async (t) => { + const res = await t.puter.perms.grantUser( + 'perms-suite-no-such-user', + `fs:${home(t)}/whatever.txt:read`, + ); + t.assert.ok(res.error, 'granting to an unknown user should error'); + }, + + 'createGroup returns a group uid': async (t) => { + const created = await t.puter.perms.createGroup({ + title: 'perms-suite-group', + }); + t.assert.ok(!created.error, `create failed: ${JSON.stringify(created)}`); + t.assert.ok(created.uid, 'created group should have a uid'); + }, + + 'listGroups includes a created group': async (t) => { + const created = await t.puter.perms.createGroup({ + title: 'perms-suite-listed-group', + }); + const groups = await t.puter.perms.listGroups(); + t.assert.ok(!groups.error, `list failed: ${JSON.stringify(groups)}`); + const all = JSON.stringify(groups); + t.assert.ok( + all.includes(created.uid), + 'listGroups should mention the created group uid', + ); + }, + + 'addUsersToGroup and removeUsersFromGroup succeed': async (t) => { + const created = await t.puter.perms.createGroup({ + title: 'perms-suite-membership', + }); + const added = await t.puter.perms.addUsersToGroup(created.uid, [ + t.env.users.other.username, + ]); + t.assert.ok(!added.error, `add failed: ${JSON.stringify(added)}`); + const removed = await t.puter.perms.removeUsersFromGroup(created.uid, [ + t.env.users.other.username, + ]); + t.assert.ok(!removed.error, `remove failed: ${JSON.stringify(removed)}`); + }, + + 'grantGroup lets group members read a file': async (t) => { + const path = `${home(t)}/perms-suite-group-shared.txt`; + await t.puter.fs.write(path, 'group content'); + + const created = await t.puter.perms.createGroup({ + title: 'perms-suite-readers', + }); + await t.puter.perms.addUsersToGroup(created.uid, [ + t.env.users.other.username, + ]); + const granted = await t.puter.perms.grantGroup( + created.uid, + `fs:${path}:read`, + ); + t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); + + const res = await readAsOther(t, path); + t.assert.equal(res.status, 200); + t.assert.equal(await res.text(), 'group content'); + }, + + 'grantApp records an app permission': async (t) => { + const app = await t.puter.apps.create( + 'perms-suite-app', + 'https://example.com/perms', + ); + const path = `${home(t)}/perms-suite-app-file.txt`; + await t.puter.fs.write(path, 'app-readable'); + const granted = await t.puter.perms.grantApp( + app.uid, + `fs:${path}:read`, + ); + t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); + const revoked = await t.puter.perms.revokeApp( + app.uid, + `fs:${path}:read`, + ); + t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); + }, +}); diff --git a/src/puter-js/tests/api/suites/system.suite.ts b/src/puter-js/tests/api/suites/system.suite.ts new file mode 100644 index 000000000..7051b3332 --- /dev/null +++ b/src/puter-js/tests/api/suites/system.suite.ts @@ -0,0 +1,67 @@ +import { suite } from '../harness/types.ts'; + +export default suite('system', { + 'os.version reports a version': async (t) => { + const version = await t.puter.os.version(); + t.assert.ok( + version && typeof version === 'object', + 'version should be an object', + ); + }, + + 'os.user returns the authenticated user': async (t) => { + const user = await t.puter.os.user(); + t.assert.equal(user.username, t.env.users.user.username); + }, + + 'drivers.list includes the core interfaces': async (t) => { + const interfaces = await t.puter.drivers.list(); + for (const expected of [ + 'puter-kvstore', + 'puter-apps', + 'puter-subdomains', + ]) { + t.assert.ok( + Object.prototype.hasOwnProperty.call(interfaces, expected), + `interfaces should include ${expected}`, + ); + } + }, + + 'drivers.call reaches a driver method generically': async (t) => { + const result = await t.puter.drivers.call( + 'puter-kvstore', + 'set', + { key: 'system-suite-driver-call', value: 'via drivers.call' }, + ); + t.assert.ok(result.success, 'driver call should succeed'); + t.assert.equal( + await t.puter.kv.get('system-suite-driver-call'), + 'via drivers.call', + ); + }, + + 'drivers.call on an unknown interface reports failure': async (t) => { + const result = await t.puter.drivers.call( + 'system-suite-no-such-interface', + 'nope', + {}, + ); + t.assert.ok( + !result?.success, + `unknown interface should not succeed: ${JSON.stringify(result)}`, + ); + }, + + 'drivers.call on an unknown method reports failure': async (t) => { + const result = await t.puter.drivers.call( + 'puter-kvstore', + 'system-suite-no-such-method', + {}, + ); + t.assert.ok( + !result?.success, + `unknown method should not succeed: ${JSON.stringify(result)}`, + ); + }, +}); diff --git a/src/puter-js/tests/api/suites/workers.suite.ts b/src/puter-js/tests/api/suites/workers.suite.ts new file mode 100644 index 000000000..5b42ac160 --- /dev/null +++ b/src/puter-js/tests/api/suites/workers.suite.ts @@ -0,0 +1,60 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +const home = (t: TestContext) => `/${t.env.users.user.username}`; + +/** + * Worker deploys go through the same pipeline the workerd runner uses + * (SDK `workers.create` → WorkerDriver → local workerd). The test env + * always routes deploys locally (see harness/capabilities.ts), so these + * run on every platform. Names must be unique per test — deploys land on + * one shared local workerd. + */ +const WORKER_SOURCE = ` +router.custom('GET', '/ping', async () => ({ pong: true })); +`; + +const deployWorker = async (t: TestContext, name: string) => { + const sourcePath = `${home(t)}/workers-suite-${name}.js`; + await t.puter.fs.write(sourcePath, WORKER_SOURCE); + return await t.puter.workers.create(name, sourcePath); +}; + +export default suite('workers', { + 'create deploys a worker and returns its url': async (t) => { + const created = await deployWorker(t, 'workers-suite-create'); + t.assert.ok(created.success, 'create should succeed'); + t.assert.ok(created.url, 'create should return the worker url'); + }, + + 'created worker responds over http': async (t) => { + const created = await deployWorker(t, 'workers-suite-exec'); + const res = await t.puter.workers.exec(`${created.url}/ping`); + t.assert.equal(res.status, 200); + const body = await res.json(); + t.assert.deepEqual(body, { pong: true }); + }, + + 'get returns the deployed worker': async (t) => { + await deployWorker(t, 'workers-suite-get'); + const worker = await t.puter.workers.get('workers-suite-get'); + t.assert.ok(worker, 'get should return the worker'); + }, + + 'list includes deployed workers': async (t) => { + await deployWorker(t, 'workers-suite-listed'); + const workers = await t.puter.workers.list(); + t.assert.ok( + JSON.stringify(workers).includes('workers-suite-listed'), + 'list should mention the deployed worker', + ); + }, + + 'delete removes the worker': async (t) => { + await deployWorker(t, 'workers-suite-delete'); + const deleted = await t.puter.workers.delete('workers-suite-delete'); + t.assert.equal(deleted, true); + const worker = await t.puter.workers.get('workers-suite-delete'); + t.assert.ok(!worker, 'deleted worker should no longer be returned'); + }, +}); diff --git a/src/puter-js/tests/api/vitest.config.ts b/src/puter-js/tests/api/vitest.config.ts index 85f806d91..f223e9cec 100644 --- a/src/puter-js/tests/api/vitest.config.ts +++ b/src/puter-js/tests/api/vitest.config.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { loadEnv } from 'vite'; import { defineConfig } from 'vitest/config'; import { lowerDecoratorsPlugin } from '../../../backend/vitest.config.ts'; @@ -10,7 +11,7 @@ const apiTestsDir = __dirname; const repoRoot = path.resolve(apiTestsDir, '../../../..'); const backendDir = path.join(repoRoot, 'src/backend'); -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [lowerDecoratorsPlugin], resolve: { alias: [ @@ -30,6 +31,10 @@ export default defineConfig({ }, test: { globals: true, + // Same PUTER_ env passthrough as the backend config, so + // PUTER_TEST_* capability vars (harness/capabilities.ts) work + // from `.env` files too. + env: loadEnv(mode, '', 'PUTER_'), 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. @@ -37,4 +42,4 @@ export default defineConfig({ hookTimeout: 120_000, root: repoRoot, }, -}); +})); diff --git a/src/puter-js/webpack.config.js b/src/puter-js/webpack.config.js index 2fea0230e..acb722161 100644 --- a/src/puter-js/webpack.config.js +++ b/src/puter-js/webpack.config.js @@ -10,16 +10,58 @@ import webpack from 'webpack'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export default { +export default (env = {}) => ({ entry: './src/index.js', output: { filename: 'puter.js', path: path.resolve(__dirname, 'dist'), }, + // `--env coverage` instruments every first-party module with istanbul + // counters (accumulated on `globalThis.__coverage__`), so the API test + // harness can measure SDK coverage in whatever runtime executes the + // bundle. Vendored code (socket.io) is left out of the numbers. + ...(env.coverage ? { + module: { + rules: [ + { + test: /\.js$/, + include: path.resolve(__dirname, 'src'), + exclude: path.resolve(__dirname, 'src/lib/socket.io'), + use: { + loader: 'babel-loader', + options: { + babelrc: false, + configFile: false, + plugins: [ + [ + 'babel-plugin-istanbul', + { + // istanbul's default global lookup + // is `new Function('return this')`, + // which workerd forbids (no dynamic + // code generation). Address the + // global directly — `self` first, + // because the worker preamble runs + // the SDK under `with (context)` + // where `globalThis` is shadowed by + // the sandbox while `self` still + // reaches the true global. + coverageGlobalScope: + "typeof self !== 'undefined' ? self : globalThis", + coverageGlobalScopeFunc: false, + }, + ], + ], + }, + }, + }, + ], + }, + } : {}), plugins: [ new webpack.DefinePlugin({ 'globalThis.PUTER_ORIGIN_ENV': JSON.stringify(process.env.PUTER_ORIGIN || 'https://puter.com'), 'globalThis.PUTER_API_ORIGIN_ENV': JSON.stringify(process.env.PUTER_API_ORIGIN || 'https://api.puter.com'), }), ], -}; +}); diff --git a/tools/puterjsCoverageReport.mjs b/tools/puterjsCoverageReport.mjs new file mode 100644 index 000000000..49a5738bf --- /dev/null +++ b/tools/puterjsCoverageReport.mjs @@ -0,0 +1,60 @@ +/** + * Merge the per-platform SDK coverage shards written by the puter.js API + * test runners (src/puter-js/coverage/shards/coverage-.json) + * into the final reports in src/puter-js/coverage: + * + * coverage-final.json / coverage-summary.json — consumed by the PR + * coverage comment action (same files the backend coverage job emits), + * lcov.info for external tooling, text (and html locally) for humans. + * + * Run via `npm run test:puterjs:coverage`, which builds the instrumented + * bundle, runs all three runners with PUTER_COVERAGE=1, then this script. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import libCoverage from 'istanbul-lib-coverage'; +import libReport from 'istanbul-lib-report'; +import reports from 'istanbul-reports'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const coverageDir = path.join(repoRoot, 'src/puter-js/coverage'); +const shardsDir = path.join(coverageDir, 'shards'); + +const shardFiles = existsSync(shardsDir) + ? readdirSync(shardsDir).filter((f) => f.endsWith('.json')) + : []; +if (shardFiles.length === 0) { + console.error( + `No coverage shards found in ${shardsDir}.\n` + + 'Run the suites in coverage mode first: npm run test:puterjs:coverage', + ); + process.exit(1); +} + +const coverageMap = libCoverage.createCoverageMap({}); +for (const file of shardFiles) { + coverageMap.merge( + JSON.parse(readFileSync(path.join(shardsDir, file), 'utf8')), + ); +} + +const context = libReport.createContext({ + dir: coverageDir, + coverageMap, +}); + +const isCi = process.env.CI === 'true'; +const reporters = ['json', 'json-summary', 'lcov', 'text']; +if (!isCi) reporters.push('html'); +for (const reporter of reporters) { + reports.create(reporter).execute(context); +} + +console.log( + `\nMerged ${shardFiles.length} shard(s): ${shardFiles.join(', ')}`, +); +console.log(`Reports written to ${coverageDir}`);