mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f976686eba | ||
|
|
7af3721543 | ||
|
|
93edec0d01 | ||
|
|
54b61f2e24 | ||
|
|
184ed77493 | ||
|
|
8234b72fc3 | ||
|
|
cc9921945e | ||
|
|
ac6f23a41f | ||
|
|
973d046d33 | ||
|
|
dcb50cde8f | ||
|
|
08beb55853 | ||
|
|
bb0220e163 | ||
|
|
f3e4c8c6d0 | ||
|
|
44254671bb | ||
|
|
bcc6cc8f00 | ||
|
|
84549a1c31 | ||
|
|
0276420213 | ||
|
|
6353a08fd5 | ||
|
|
c265fa308a | ||
|
|
d3f72bc33a | ||
|
|
700fc4d27e | ||
|
|
9c7f30a150 | ||
|
|
85e45b30f5 | ||
|
|
5483e32eab | ||
|
|
f63c7d8142 | ||
|
|
5a2071f852 | ||
|
|
238ed12e28 | ||
|
|
d1afe6c3cf | ||
|
|
e58dd48e80 | ||
|
|
69f87d4dcd | ||
|
|
7b0dd11f7f | ||
|
|
620934fbf4 | ||
|
|
182fc723f1 | ||
|
|
5d3c5b6873 | ||
|
|
f20445bab7 | ||
|
|
8ad1a58f98 | ||
|
|
17e475236a | ||
|
|
b948eaaaa6 | ||
|
|
9b0c034247 | ||
|
|
b0dc9c7f1b | ||
|
|
e79abd43e3 | ||
|
|
36c9c478ec | ||
|
|
90d01b87ea | ||
|
|
b0823449bc | ||
|
|
760d5d346d | ||
|
|
4e11d1dd0f | ||
|
|
5e8c66142d | ||
|
|
189dae25ef | ||
|
|
b2f4a86224 | ||
|
|
122811eb7a | ||
|
|
bc98dacf65 | ||
|
|
b593eb5630 |
No files matched your search
+1
-1
@@ -6,7 +6,7 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Connect with the maintainers and community through these channels:
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Security issues? [[email protected]](mailto:[email protected])
|
||||
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers at [[email protected]](mailto:[email protected])
|
||||
|
||||
We are always happy to help you with any questions you may have. Don't hesitate to ask!
|
||||
|
||||
+12
-9
@@ -58,7 +58,10 @@ services:
|
||||
fi
|
||||
wait $$SERVER_PID
|
||||
volumes:
|
||||
- ./puter/data/valkey:/data
|
||||
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
|
||||
# everywhere else) — without it those distros deny container
|
||||
# access to the bind mount and the service loops on EACCES.
|
||||
- ./puter/data/valkey:/data:z
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
|
||||
@@ -77,7 +80,7 @@ services:
|
||||
MARIADB_USER: ${MARIADB_USER:-puter}
|
||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
|
||||
volumes:
|
||||
- ./puter/data/mariadb:/var/lib/mysql
|
||||
- ./puter/data/mariadb:/var/lib/mysql:z
|
||||
healthcheck:
|
||||
# `healthcheck.sh` ships with the mariadb image; --connect verifies
|
||||
# the server is accepting auth, not just listening on the socket.
|
||||
@@ -102,7 +105,7 @@ services:
|
||||
- "-dbPath"
|
||||
- "/home/dynamodblocal/data"
|
||||
volumes:
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data:z
|
||||
|
||||
s3:
|
||||
# RustFS — S3-compatible object storage. Drop-in alternative:
|
||||
@@ -114,7 +117,7 @@ services:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data
|
||||
- ./puter/data/s3:/data:z
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
@@ -180,7 +183,7 @@ services:
|
||||
container_name: puter-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./puter/data/ollama:/root/.ollama
|
||||
- ./puter/data/ollama:/root/.ollama:z
|
||||
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
|
||||
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
|
||||
# ports:
|
||||
@@ -257,9 +260,9 @@ services:
|
||||
PGID: 1000
|
||||
volumes:
|
||||
# Drop your config.json here — see selfhosted/full-stack.md.
|
||||
- ./puter/config:/etc/puter
|
||||
- ./puter/config:/etc/puter:z
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
interval: 30s
|
||||
@@ -279,9 +282,9 @@ services:
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro
|
||||
- ./puter/tls:/etc/nginx/tls:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
|
||||
+12
-1
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import localeEn from 'javascript-time-ago/locale/en';
|
||||
|
||||
@@ -94,6 +94,8 @@ export const handleWhoami = async (
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
@@ -171,6 +173,15 @@ export const handleWhoami = async (
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
|
||||
+12
@@ -50,6 +50,18 @@ docker compose version >/dev/null 2>&1 \
|
||||
mkdir -p "$PUTER_DIR"
|
||||
cd "$PUTER_DIR"
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
# Pre-create per-service data dirs and make them writable by any UID.
|
||||
# Several upstream images run as non-root inside the container (rustfs
|
||||
# uses UID 10001; dynamo is pinned to 1000 in compose), and rustfs's
|
||||
# entrypoint runs as that same non-root user so it can't chown an
|
||||
# already-existing bind-mounted dir. On hosts where the user that ran
|
||||
# this script has a UID that doesn't match — or where docker is running
|
||||
# rootless — those containers loop on EACCES at startup. 0777 on the
|
||||
# bind-mount roots sidesteps the mismatch without guessing each image's
|
||||
# internal UID. (Docker Desktop on macOS/Windows papers over this with
|
||||
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
|
||||
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
log "install dir: $(pwd)"
|
||||
|
||||
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
|
||||
|
||||
Generated
+24
@@ -6270,6 +6270,29 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
|
||||
@@ -19206,6 +19229,7 @@
|
||||
"open": "^10.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"webpack-cli": "^5.1.4"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { basename, extname, join, resolve } from 'path';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import type { IConfig } from '../../types';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
@@ -102,6 +102,10 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
: (this.config.database?.path ?? ':memory:');
|
||||
const isNew = dbPath === ':memory:' || !existsSync(dbPath);
|
||||
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
await this.runMigrations(isNew);
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import validator from 'validator';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
@@ -41,8 +41,8 @@ import {
|
||||
createSecret as otpCreateSecret,
|
||||
verify as verifyOtp,
|
||||
} from '../../services/auth/OTPUtil.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
@@ -94,7 +94,6 @@ export class AuthController extends PuterController {
|
||||
// ── Login ───────────────────────────────────────────────────────
|
||||
|
||||
@Post('/login', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
|
||||
})
|
||||
@@ -191,7 +190,6 @@ export class AuthController extends PuterController {
|
||||
// ── Login: OTP verification ─────────────────────────────────────
|
||||
|
||||
@Post('/login/otp', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: {
|
||||
scope: 'login-otp',
|
||||
@@ -246,7 +244,6 @@ export class AuthController extends PuterController {
|
||||
// ── Login: recovery code ────────────────────────────────────────
|
||||
|
||||
@Post('/login/recovery-code', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: {
|
||||
scope: 'login-recovery',
|
||||
@@ -314,7 +311,6 @@ export class AuthController extends PuterController {
|
||||
// ── Signup ──────────────────────────────────────────────────────
|
||||
|
||||
@Post('/signup', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
|
||||
})
|
||||
@@ -455,17 +451,7 @@ export class AuthController extends PuterController {
|
||||
// - skip temp-user creation via `event.no_temp_user = true`
|
||||
// Listeners run sequentially so multi-signal checks (rate limit +
|
||||
// IP reputation + domain reputation) can short-circuit cleanly.
|
||||
const validateEvent: {
|
||||
req: Request;
|
||||
data: Record<string, unknown>;
|
||||
ip: string | null;
|
||||
email: string | undefined;
|
||||
allow: boolean;
|
||||
no_temp_user: boolean;
|
||||
requires_email_confirmation: boolean;
|
||||
message: string | null;
|
||||
code: string | null;
|
||||
} = {
|
||||
const validateEvent = {
|
||||
req,
|
||||
data: body,
|
||||
ip: ((req.headers?.['x-forwarded-for'] as string | undefined) ||
|
||||
@@ -480,6 +466,7 @@ export class AuthController extends PuterController {
|
||||
requires_email_confirmation: false,
|
||||
message: null,
|
||||
code: null,
|
||||
user_agent: req?.headers?.['user-agent'] ?? null,
|
||||
};
|
||||
try {
|
||||
await this.clients.event?.emitAndWait(
|
||||
@@ -707,14 +694,13 @@ export class AuthController extends PuterController {
|
||||
// ── Logout ──────────────────────────────────────────────────────
|
||||
|
||||
@Post('/logout', {
|
||||
subdomain: ['api', ''],
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
})
|
||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||
// Clear the session cookie
|
||||
res.clearCookie(this.config.cookie_name);
|
||||
res.clearCookie(this.config.cookie_name!);
|
||||
|
||||
// Remove the session (fire-and-forget)
|
||||
if (req.token) {
|
||||
@@ -837,6 +823,16 @@ export class AuthController extends PuterController {
|
||||
email_confirm_token: null,
|
||||
});
|
||||
|
||||
// Revoke confirmation from any other accounts sharing this
|
||||
// email so only the account whose owner just proved inbox
|
||||
// access retains verified status.
|
||||
const canonical = cleanEmail(user.email!);
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email!,
|
||||
canonical,
|
||||
);
|
||||
|
||||
await promoteToVerifiedGroup(this.stores.group, this.config, user);
|
||||
|
||||
try {
|
||||
@@ -1552,7 +1548,6 @@ export class AuthController extends PuterController {
|
||||
// ── Anti-CSRF token generation ──────────────────────────────────
|
||||
|
||||
@Get('/get-anticsrf-token', {
|
||||
subdomain: '',
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -2326,7 +2321,6 @@ export class AuthController extends PuterController {
|
||||
// ── Session helpers ─────────────────────────────────────────────
|
||||
|
||||
@Get('/get-gui-token', {
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -2348,7 +2342,6 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
@Get('/session/sync-cookie', {
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -2382,7 +2375,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
res.clearCookie(this.config.cookie_name);
|
||||
res.clearCookie(this.config.cookie_name!);
|
||||
res.clearCookie('puter_revalidation');
|
||||
await this.#cascadeDeleteUser(userId);
|
||||
res.json({ success: true });
|
||||
@@ -2442,7 +2435,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-password',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'passwd',
|
||||
@@ -2462,7 +2454,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-username',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
@@ -2483,7 +2474,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-email',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'change-email-start',
|
||||
@@ -2503,7 +2493,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/disable-2fa',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'disable-2fa',
|
||||
@@ -2523,7 +2512,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/delete-own-user',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
middleware: [
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Busboy from 'busboy';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -90,7 +91,7 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
|
||||
|
||||
@Controller('/fs')
|
||||
export class FSController extends PuterController {
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
override getReportedCosts() {
|
||||
return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({
|
||||
usageType,
|
||||
ucentsPerUnit,
|
||||
@@ -1787,7 +1788,8 @@ export class FSController extends PuterController {
|
||||
pathToNormalize = `/${username}${pathToNormalize.slice(1)}`;
|
||||
}
|
||||
|
||||
let normalizedPath = pathPosix.normalize(pathToNormalize);
|
||||
assertNormalized(pathToNormalize);
|
||||
let normalizedPath = pathToNormalize;
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
signingConfigFromAppConfig,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
|
||||
/**
|
||||
* Legacy FS routes, implemented as thin shims over `FSService`.
|
||||
@@ -79,11 +80,16 @@ export class LegacyFSController extends PuterController {
|
||||
#additionalCache: RouterCache = new Map();
|
||||
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
const apiOptions = { subdomain: 'api', requireVerified: true } as const;
|
||||
const apiOptions = {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
} as RouteOptions;
|
||||
// Signed-URL routes: the handler validates the URL signature itself,
|
||||
// so no auth gate is applied (matches v1, which mounted these routers
|
||||
// with no middleware).
|
||||
const signedOptions = { subdomain: 'api' } as const;
|
||||
const signedOptions = {
|
||||
subdomain: 'api',
|
||||
} as RouteOptions;
|
||||
|
||||
// Core filesystem_api routes — direct handlers over the FS service.
|
||||
router.post('/stat', apiOptions, this.stat);
|
||||
@@ -98,7 +104,11 @@ export class LegacyFSController extends PuterController {
|
||||
router.get('/read', apiOptions, this.read);
|
||||
router.get(
|
||||
'/token-read',
|
||||
{ subdomain: 'api', requireVerified: false },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireVerified: false,
|
||||
allowAccessToken: true,
|
||||
},
|
||||
this.tokenRead,
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
import type { FSService } from '../../services/fs/FSService.js';
|
||||
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isAppActor, type Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
@@ -202,8 +202,8 @@ export async function assertAccess(
|
||||
// App-under-user actors see denials as 404 "subject_does_not_exist"
|
||||
// so existence of a sibling user's / other-app's files isn't leaked
|
||||
// through the error code. User-actor denials keep the real 403.
|
||||
const isAppActor = Boolean((actor as { app?: unknown })?.app);
|
||||
if (isAppActor) {
|
||||
|
||||
if (isAppActor(actor)) {
|
||||
throw new HttpError(404, `Entry not found: path=${path}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-ack — user dismissed a notification.
|
||||
* Sets `acknowledged` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-ack', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
|
||||
async markAck(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
@@ -77,7 +77,7 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-read — user saw a notification.
|
||||
* Sets `shown` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-read', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
|
||||
async markRead(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
|
||||
@@ -73,11 +73,6 @@ const makeUserActor = (): Actor => ({
|
||||
user: { id: 7, uuid: 'u-7', username: 'alice' },
|
||||
});
|
||||
|
||||
const makeAppActor = (): Actor => ({
|
||||
user: { id: 7, uuid: 'u-7', username: 'alice' },
|
||||
app: { id: 1, uid: 'app-uid' },
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
@@ -193,13 +188,23 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const chatRoute = calls.find(
|
||||
(c) => c.path === '/puterai/openai/v1/chat/completions',
|
||||
);
|
||||
expect(chatRoute?.opts).toEqual({
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
});
|
||||
// The four upstream-proxy routes are user-only: app actors must
|
||||
// call puter-chat-completion directly. The `requireUserActor`
|
||||
// route option wires up the gate that enforces this, so each
|
||||
// proxy route gets the assertion (not just chat/completions).
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
'/puterai/openai/v1/responses',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
];
|
||||
for (const path of userOnlyPaths) {
|
||||
const route = calls.find((c) => c.path === path);
|
||||
expect(route?.opts).toEqual({
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
});
|
||||
}
|
||||
const modelsRoute = calls.find(
|
||||
(c) => c.path === '/puterai/chat/models',
|
||||
);
|
||||
@@ -213,18 +218,10 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiChatCompletions', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: { messages: [], model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// Note: app-actor rejection (403) is enforced by the `requireUserActor`
|
||||
// route gate and is asserted in the registerRoutes test above. The
|
||||
// handler itself no longer does that check, so unit-testing it here
|
||||
// would require invoking the gate stack.
|
||||
|
||||
it('rejects bodies missing a messages array with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -272,13 +269,13 @@ describe('PuterAIController.openaiChatCompletions', () => {
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.object).toBe('chat.completion');
|
||||
expect(body.model).toBe('gpt-test');
|
||||
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hi there' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(body.choices as Array<Record<string, unknown>>)[0],
|
||||
).toMatchObject({
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hi there' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
// total_tokens is computed from prompt + completion.
|
||||
expect(body.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
@@ -392,18 +389,8 @@ describe('PuterAIController.openaiChatCompletions', () => {
|
||||
// ── /openai/v1/completions ──────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiCompletions', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiCompletions(
|
||||
makeReq({
|
||||
body: { prompt: 'hi', model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects a non-string prompt with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -443,13 +430,13 @@ describe('PuterAIController.openaiCompletions', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.object).toBe('text_completion');
|
||||
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
|
||||
{
|
||||
text: 'response',
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(body.choices as Array<Record<string, unknown>>)[0],
|
||||
).toMatchObject({
|
||||
text: 'response',
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((body.id as string).startsWith('cmpl-')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -457,18 +444,8 @@ describe('PuterAIController.openaiCompletions', () => {
|
||||
// ── /openai/v1/responses ────────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiResponses', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { input: 'hi', model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects providers other than openai-responses with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -537,18 +514,8 @@ describe('PuterAIController.openaiResponses', () => {
|
||||
// ── /anthropic/v1/messages ──────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.anthropicMessages', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: { messages: [], model: 'claude-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects bodies missing a messages array with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -655,9 +622,8 @@ describe('PuterAIController model listing', () => {
|
||||
const captureGetHandler = (
|
||||
path: string,
|
||||
): ((req: Request, res: Response) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((p: string, _opts: unknown, h: never) => {
|
||||
@@ -719,9 +685,8 @@ describe('PuterAIController videoProxy', () => {
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((path: string, _opts: unknown, h: never) => {
|
||||
@@ -799,8 +764,9 @@ describe('PuterAIController videoProxy', () => {
|
||||
it('500s when url_signature_secret is not configured', async () => {
|
||||
// Temporarily blank the secret so the controller hits the
|
||||
// 500 branch instead of the constant-time-compare gate.
|
||||
const cfg = (controller as unknown as { config: Record<string, unknown> })
|
||||
.config;
|
||||
const cfg = (
|
||||
controller as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
const orig = cfg.url_signature_secret;
|
||||
cfg.url_signature_secret = undefined;
|
||||
try {
|
||||
@@ -826,8 +792,9 @@ describe('PuterAIController videoProxy', () => {
|
||||
// Hit the post-signature `provider !== 'gemini'` branch by
|
||||
// computing a valid signature for a known fileId/expires combo
|
||||
// and then sending a different provider in the query.
|
||||
const cfg = (controller as unknown as { config: Record<string, unknown> })
|
||||
.config;
|
||||
const cfg = (
|
||||
controller as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
const secret = cfg.url_signature_secret as string;
|
||||
const fileId = 'abc-123';
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
@@ -854,11 +821,16 @@ describe('PuterAIController videoProxy', () => {
|
||||
});
|
||||
|
||||
it('500s when provider=gemini but no Gemini API key is configured', async () => {
|
||||
const cfg = (controller as unknown as {
|
||||
config: Record<string, unknown> & {
|
||||
providers?: Record<string, Record<string, unknown> | undefined>;
|
||||
};
|
||||
}).config;
|
||||
const cfg = (
|
||||
controller as unknown as {
|
||||
config: Record<string, unknown> & {
|
||||
providers?: Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
>;
|
||||
};
|
||||
}
|
||||
).config;
|
||||
const secret = cfg.url_signature_secret as string;
|
||||
const fileId = 'gemini-no-key';
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
@@ -1425,7 +1397,11 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
);
|
||||
const tools = completeSpy.mock.calls[0]![0].tools as Array<{
|
||||
type: string;
|
||||
function: { name: string; description: string; parameters: unknown };
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
};
|
||||
}>;
|
||||
expect(tools[0]?.type).toBe('function');
|
||||
expect(tools[0]?.function.name).toBe('lookup');
|
||||
@@ -1483,9 +1459,7 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { content: Array<{ text: string }> };
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'hello world' },
|
||||
]);
|
||||
expect(body.content).toEqual([{ type: 'text', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('reads tool_use blocks from message.content (not just message.tool_calls)', async () => {
|
||||
@@ -1536,9 +1510,8 @@ describe('PuterAIController model details', () => {
|
||||
const captureGetHandler = (
|
||||
path: string,
|
||||
): ((req: Request, res: Response) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((p: string, _opts: unknown, h: never) => {
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { isAppActor } from '../../core/actor.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
ICompleteArguments,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const GEMINI_DOWNLOAD_BASE =
|
||||
'https://generativelanguage.googleapis.com/download/v1beta/files';
|
||||
@@ -49,7 +49,10 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
const apiAuthOpts = { subdomain: 'api', requireAuth: true } as const;
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
} as RouteOptions;
|
||||
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
|
||||
|
||||
// Every route below carries the `/puterai` prefix for wire
|
||||
@@ -229,8 +232,6 @@ export class PuterAIController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -396,8 +397,6 @@ export class PuterAIController extends PuterController {
|
||||
// ── /openai/v1/completions ──────────────────────────────────────
|
||||
|
||||
openaiCompletions = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -526,8 +525,6 @@ export class PuterAIController extends PuterController {
|
||||
// ── /openai/v1/responses ────────────────────────────────────────
|
||||
|
||||
openaiResponses = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -838,8 +835,6 @@ export class PuterAIController extends PuterController {
|
||||
// ── /anthropic/v1/messages ──────────────────────────────────────
|
||||
|
||||
anthropicMessages = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -1052,17 +1047,6 @@ export class PuterAIController extends PuterController {
|
||||
});
|
||||
return driver;
|
||||
}
|
||||
|
||||
#rejectAppActor(req: Request): void {
|
||||
// Proxy routes are user-only; apps must call puter-chat-completion directly.
|
||||
if (isAppActor(req.actor)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'App actors may not proxy to upstream AI APIs',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
|
||||
@@ -92,7 +92,10 @@ export const PuterController =
|
||||
public onServerShutdown() {
|
||||
return;
|
||||
}
|
||||
public async getReportedCosts() {
|
||||
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[] {
|
||||
return [];
|
||||
}
|
||||
public registerRoutes(_router: PuterRouter) {}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const isSystemActor = (actor: Actor | undefined | null): boolean => {
|
||||
};
|
||||
|
||||
export const isAppActor = (actor: Actor | undefined | null): boolean => {
|
||||
return !!actor?.app && !actor?.accessToken;
|
||||
return !!actor?.app && !isAccessTokenActor(actor);
|
||||
};
|
||||
|
||||
export const isAccessTokenActor = (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// NOT ALL OF THEM, ADD OLD ONES AS NEEDED, IF NEEDED. DO NOT ADD NEW ONES THOUGH.
|
||||
export type LegacyErrorCodes =
|
||||
| 'payment_account_not_set_up'
|
||||
| 'unknown_error'
|
||||
| 'disallowed_value'
|
||||
| 'invalid_token'
|
||||
|
||||
@@ -80,6 +80,9 @@ declare global {
|
||||
|
||||
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
|
||||
is_custom_domain?: boolean;
|
||||
|
||||
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
|
||||
cookies?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,33 @@ interface ReqInit {
|
||||
query?: Record<string, unknown>;
|
||||
handshakeQuery?: Record<string, unknown>;
|
||||
actor?: Actor;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
// Minimal stand-in for `cookie-parser` — splits on `;`, URL-decodes the
|
||||
// value, and strips a pair of surrounding double quotes (matches the
|
||||
// `cookie` package's behavior, which `cookie-parser` uses internally).
|
||||
const parseCookieHeader = (header: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const piece of header.split(';')) {
|
||||
const eq = piece.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const name = piece.slice(0, eq).trim();
|
||||
if (!name) continue;
|
||||
let value = piece.slice(eq + 1).trim();
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
/* leave as-is */
|
||||
}
|
||||
out[name] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const makeReq = (init: ReqInit = {}): Request => {
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
if (init.cookieHeader) headers.cookie = init.cookieHeader;
|
||||
@@ -82,11 +107,15 @@ const makeReq = (init: ReqInit = {}): Request => {
|
||||
body: init.body,
|
||||
query: (init.query ?? {}) as Request['query'],
|
||||
headers: headers as unknown as Request['headers'],
|
||||
protocol: init.protocol,
|
||||
header(name: string) {
|
||||
// Express's `req.header()` is case-insensitive; mirror that.
|
||||
return headers[name.toLowerCase()];
|
||||
return headers[name.toLowerCase()] as unknown as string[] & string;
|
||||
},
|
||||
};
|
||||
if (init.cookieHeader) {
|
||||
req.cookies = parseCookieHeader(init.cookieHeader);
|
||||
}
|
||||
if (init.actor) req.actor = init.actor;
|
||||
if (init.handshakeQuery) {
|
||||
req.handshake = { query: init.handshakeQuery };
|
||||
@@ -303,10 +332,7 @@ describe('createAuthProbe — cookie reading', () => {
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
// Quoted + percent-encoded value: `"a b"` → `a b`
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({ cookieHeader: 'puter_token="a%20b"' }),
|
||||
);
|
||||
await runProbe(probe, makeReq({ cookieHeader: 'puter_token="a%20b"' }));
|
||||
expect(stub.seenTokens).toEqual(['a b']);
|
||||
});
|
||||
|
||||
@@ -319,6 +345,114 @@ describe('createAuthProbe — cookie reading', () => {
|
||||
);
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores session cookies on cross-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://attacker.example',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
expect(req.actor).toBeUndefined();
|
||||
expect(req.tokenAuthFailed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts bearer tokens on cross-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
authorization: 'Bearer header-tok',
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://app.example',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['header-tok']);
|
||||
});
|
||||
|
||||
it('keeps session cookies for same-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['session-abc']);
|
||||
});
|
||||
|
||||
it('normalizes default ports when comparing browser origins', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test:443',
|
||||
origin: 'https://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['session-abc']);
|
||||
});
|
||||
|
||||
it('treats protocol mismatches as cross-origin for session cookies', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'http://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Behavior when AuthService responds ──────────────────────────────
|
||||
@@ -348,7 +482,7 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
expect(req.tokenAuthFailed).toBe(true);
|
||||
});
|
||||
|
||||
it("never rejects — sets tokenAuthFailed even when AuthService throws", async () => {
|
||||
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNext('throw');
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
|
||||
@@ -44,7 +44,7 @@ interface AuthProbeOptions {
|
||||
* 1. `req.body.auth_token`
|
||||
* 2. `Authorization: Bearer <token>` header
|
||||
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
|
||||
* 4. Session cookie
|
||||
* 4. Session cookie, only when the browser request is same-origin
|
||||
* 5. `?auth_token=...` query param
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
@@ -126,12 +126,13 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
return stripBearer(xApiKey);
|
||||
}
|
||||
|
||||
// 4. Cookie (set by login flow for session tokens). We parse the
|
||||
// Cookie header directly rather than depending on `cookie-parser`
|
||||
// middleware — the probe only needs one named value.
|
||||
if (cookieName) {
|
||||
const cookieToken = readCookie(req, cookieName);
|
||||
if (cookieToken) {
|
||||
// 4. Cookie (set by login flow for session tokens). Do not let an
|
||||
// arbitrary browser Origin spend an ambient session cookie against the
|
||||
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
|
||||
// available for cross-origin SDK requests.
|
||||
if (cookieName && !isCrossOriginBrowserRequest(req)) {
|
||||
const cookieToken = req.cookies?.[cookieName];
|
||||
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
|
||||
return stripBearer(cookieToken);
|
||||
}
|
||||
}
|
||||
@@ -157,27 +158,25 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
|
||||
const stripBearer = (t: string): string => t.replace(/^Bearer\s+/i, '').trim();
|
||||
|
||||
/**
|
||||
* Minimal cookie reader. Avoids pulling in `cookie-parser` for the one
|
||||
* lookup the probe needs. Handles quoted values and URL-decodes the result.
|
||||
*/
|
||||
const readCookie = (req: Request, name: string): string | null => {
|
||||
const header =
|
||||
typeof req.header === 'function' ? req.header('cookie') : undefined;
|
||||
if (!header || typeof header !== 'string') return null;
|
||||
const target = `${name}=`;
|
||||
for (const rawPair of header.split(';')) {
|
||||
const pair = rawPair.trim();
|
||||
if (!pair.startsWith(target)) continue;
|
||||
let value = pair.slice(target.length);
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
const isCrossOriginBrowserRequest = (req: Request): boolean => {
|
||||
const origin =
|
||||
typeof req.header === 'function' ? req.header('origin') : undefined;
|
||||
if (!origin) return false;
|
||||
|
||||
const host =
|
||||
typeof req.header === 'function' ? req.header('host') : undefined;
|
||||
if (!host) return true;
|
||||
|
||||
const protocol =
|
||||
typeof req.protocol === 'string' && req.protocol.length > 0
|
||||
? req.protocol
|
||||
: undefined;
|
||||
if (!protocol) return true;
|
||||
|
||||
try {
|
||||
const requestOrigin = new URL(`${protocol}://${host.trim()}`).origin;
|
||||
return new URL(origin).origin !== requestOrigin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -114,6 +114,27 @@ export const requireUserActorGate = (): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
if (actor.accessToken) {
|
||||
next(
|
||||
new HttpError(
|
||||
403,
|
||||
'Access tokens are not allowed to access this resource',
|
||||
{ legacyCode: 'forbidden' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/** Built-in admin usernames that always pass `adminOnly`. */
|
||||
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RequestHandler } from 'express';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { IConfig } from '../../../types';
|
||||
import { assertNormalized } from '../../../services/fs/resolveNode.js';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
@@ -130,10 +131,8 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
|
||||
? path.join(root, active, 'dist')
|
||||
: path.join(root, active);
|
||||
|
||||
// req.path is already url-decoded by express; normalize strips any
|
||||
// `..` segments before sendFile's `root` option enforces its own
|
||||
// traversal guard.
|
||||
const requested = path.normalize(req.path);
|
||||
const requested = req.path;
|
||||
assertNormalized(requested);
|
||||
const absolute = path.join(appRoot, requested);
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
buildPrivateHostRedirect,
|
||||
buildPublicHostRedirect,
|
||||
getBootstrapToken,
|
||||
hostMatchesPrivateDomain,
|
||||
normalizeHost,
|
||||
@@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPublicHostRedirect', () => {
|
||||
// Mirror of buildPrivateHostRedirect — swaps the private hosting
|
||||
// domain for the public one. Used when a non-private app (or no app
|
||||
// at all) hits the private host, so a paid-→-free app's old
|
||||
// `puter.app` URL still resolves on `puter.site`.
|
||||
const cfg = buildHostingConfig({
|
||||
domain: 'puter.localhost',
|
||||
static_hosting_domain: 'site.puter.localhost:4100',
|
||||
static_hosting_domain_alt: null,
|
||||
private_app_hosting_domain: 'app.puter.localhost',
|
||||
private_app_hosting_domain_alt: null,
|
||||
protocol: 'http',
|
||||
} as unknown as IConfig);
|
||||
|
||||
const reqOf = (init: Partial<Request>): Request =>
|
||||
({
|
||||
hostname: init.hostname,
|
||||
originalUrl: init.originalUrl,
|
||||
protocol: init.protocol ?? 'http',
|
||||
headers: init.headers ?? {},
|
||||
}) as unknown as Request;
|
||||
|
||||
it('swaps the private hosting domain for the public one (preserving port + path + query)', () => {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.app.puter.localhost',
|
||||
originalUrl: '/some/path?x=1',
|
||||
}),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.site.puter.localhost:4100/some/path?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the path to '/' when originalUrl is empty", () => {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'beans.app.puter.localhost' }),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe('http://beans.site.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('returns null when no public hosting domain is configured', () => {
|
||||
const noPublic = {
|
||||
...cfg,
|
||||
staticDomains: [],
|
||||
staticDomainsRaw: [],
|
||||
};
|
||||
expect(
|
||||
buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'beans.app.puter.localhost' }),
|
||||
noPublic,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for the bare private host (no subdomain to forward)', () => {
|
||||
expect(
|
||||
buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'app.puter.localhost' }),
|
||||
cfg,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Login bootstrap HTML ────────────────────────────────────────────
|
||||
|
||||
describe('renderLoginBootstrapHtml', () => {
|
||||
|
||||
@@ -564,6 +564,38 @@ export function buildPrivateHostRedirect(
|
||||
void app; // reserved for future use (logging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
|
||||
* equivalent URL for a request that landed on the private hosting domain
|
||||
* but doesn't belong there (non-private app, or no app at all). Used so a
|
||||
* formerly-paid app that's now free (or a plain hosted site) resolves on
|
||||
* `puter.site` instead of 404ing on `puter.app`.
|
||||
*/
|
||||
export function buildPublicHostRedirect(
|
||||
req: Request,
|
||||
config: PrivateHostingConfig,
|
||||
): string | null {
|
||||
const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0];
|
||||
if (!publicDomain) return null;
|
||||
const host = normalizeHost(req.hostname);
|
||||
if (!host) return null;
|
||||
const subdomain = subdomainFromHost(host, [
|
||||
...config.staticDomains,
|
||||
...config.privateDomains,
|
||||
]);
|
||||
if (!subdomain) return null;
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${publicDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect URL when private access is denied — lands on the app-center listing. */
|
||||
export function buildAppCenterFallback(
|
||||
app: AppLike,
|
||||
|
||||
@@ -114,6 +114,7 @@ const makeRes = () => {
|
||||
const makeReq = (init: {
|
||||
hostname: string;
|
||||
path?: string;
|
||||
originalUrl?: string;
|
||||
protocol?: string;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
@@ -121,6 +122,9 @@ const makeReq = (init: {
|
||||
({
|
||||
hostname: init.hostname,
|
||||
path: init.path ?? '/',
|
||||
// Redirect helpers use originalUrl (preserves query string).
|
||||
// Default to `path` when caller doesn't care to distinguish.
|
||||
originalUrl: init.originalUrl ?? init.path ?? '/',
|
||||
protocol: init.protocol ?? 'http',
|
||||
headers: init.headers ?? {},
|
||||
cookies: init.cookies ?? {},
|
||||
@@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Private hosting domain refusal ──────────────────────────────────
|
||||
// ── Private hosting domain → public-host redirect ───────────────────
|
||||
|
||||
describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => {
|
||||
it('302s a subdomain on the private host with no private app to the equivalent puter.site URL (covers freed-paid-app bookmarks + plain hosted sites)', async () => {
|
||||
// Owner exists, subdomain exists, but it has no associated
|
||||
// private app. On the *private* host this must refuse, not serve.
|
||||
// private app. On the *private* host this used to 404; now it
|
||||
// mirrors the public→private redirect so a paid app whose price
|
||||
// dropped to 0 still resolves on `puter.site` when accessed via
|
||||
// its old `puter.app` URL.
|
||||
const owner = await makeUser();
|
||||
const sub = `leak-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
@@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
makeReq({
|
||||
// Note: app.puter.localhost is the *private* hosting domain.
|
||||
hostname: `${sub}.app.puter.localhost`,
|
||||
path: '/some/deep/path.html',
|
||||
}),
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Subdomain not found');
|
||||
expect(out.redirected).toEqual({
|
||||
status: 302,
|
||||
url: `http://${sub}.site.puter.localhost/some/deep/path.html`,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the alt private hosting domain when configured', async () => {
|
||||
it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => {
|
||||
// Coverage for the `private_app_hosting_domain_alt` slot — same
|
||||
// refusal logic, but via the alternate host that the deployment
|
||||
// redirect logic, but via the alternate host that the deployment
|
||||
// can use for legacy traffic.
|
||||
const owner = await makeUser();
|
||||
const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
mw,
|
||||
makeReq({ hostname: `${sub}.apps.alt.localhost` }),
|
||||
);
|
||||
expect(out.redirected).toEqual({
|
||||
status: 302,
|
||||
url: `http://${sub}.site.puter.localhost/`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to 404 when no public hosting domain is configured (no leak)', async () => {
|
||||
// Without a static_hosting_domain to redirect to we have no safe
|
||||
// target; the original refusal must still apply so a public-app
|
||||
// subdomain doesn't accidentally serve via the private host.
|
||||
const owner = await makeUser();
|
||||
const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
});
|
||||
const mw = createPuterSiteMiddleware(
|
||||
{
|
||||
...hostingConfig,
|
||||
static_hosting_domain: null,
|
||||
} as unknown as IConfig,
|
||||
{
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
services: server.services,
|
||||
},
|
||||
);
|
||||
const { out } = await runMiddleware(
|
||||
mw,
|
||||
makeReq({ hostname: `${sub}.app.puter.localhost` }),
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Subdomain not found');
|
||||
expect(out.redirected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('<html>hi</html>');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('default doc');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -657,11 +689,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
const body = Buffer.from('not a directory');
|
||||
// Write a file and use ITS id as the subdomain's root_dir_id —
|
||||
// the middleware must reject because root must be a directory.
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/Documents/somefile.txt`,
|
||||
body,
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body);
|
||||
const fileEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`${homePath}/Documents/somefile.txt`,
|
||||
);
|
||||
@@ -733,12 +761,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('inside');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/safe.txt`,
|
||||
body,
|
||||
'text/plain',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
buildPrivateHostRedirect,
|
||||
buildPublicHostRedirect,
|
||||
hostMatchesPrivateDomain,
|
||||
renderLoginBootstrapHtml,
|
||||
resolvePrivateAppForHostedSite,
|
||||
@@ -344,8 +345,20 @@ export const createPuterSiteMiddleware = (
|
||||
// third-party resources loaded from the app.
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
} else if (privateHostingDomains.has(matched)) {
|
||||
// Private host with no private app → refuse. Prevents a
|
||||
// public-app subdomain from leaking via the private host.
|
||||
// Non-private content landed on the private hosting domain —
|
||||
// mirror of the private redirect above. Covers two cases:
|
||||
// - a paid app that just flipped to free (is_private 1→0)
|
||||
// whose old `puter.app` URL is still bookmarked/shared;
|
||||
// - a plain hosted site that has no associated app.
|
||||
// Redirect to the equivalent `puter.site` URL so the content
|
||||
// resolves on the correct origin instead of 404ing.
|
||||
const redirectUrl = buildPublicHostRedirect(req, hostingCfg);
|
||||
if (redirectUrl) {
|
||||
res.redirect(302, redirectUrl);
|
||||
return;
|
||||
}
|
||||
// No public hosting domain configured — fall back to refusing
|
||||
// rather than leaking via the private host.
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -80,12 +80,15 @@ export interface RouteOptions {
|
||||
*/
|
||||
subdomain?: string | string[];
|
||||
|
||||
/** Reject anonymous + suspended-user requests with 401/403. */
|
||||
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
|
||||
requireAuth?: boolean;
|
||||
|
||||
/** Reject app/access-token actors. Implies `requireAuth`. */
|
||||
requireUserActor?: boolean;
|
||||
|
||||
/** Allows access-tokens */
|
||||
allowAccessToken?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's username is `admin`, `system`, or one of the
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
|
||||
@@ -41,6 +41,7 @@ import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
|
||||
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
|
||||
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
@@ -751,13 +752,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const openrouter = providers['openrouter'];
|
||||
const openrouterKey = readKey(openrouter);
|
||||
if (openrouterKey) {
|
||||
this.#providers['openrouter'] = new OpenRouterProvider(
|
||||
const alibaba = providers['alibaba'];
|
||||
const alibabaKey = readKey(alibaba);
|
||||
if (alibabaKey) {
|
||||
this.#providers['alibaba'] = new AlibabaProvider(
|
||||
{
|
||||
apiKey: openrouterKey,
|
||||
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
|
||||
apiKey: alibabaKey,
|
||||
apiBaseUrl: alibaba?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
@@ -782,6 +783,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const openrouter = providers['openrouter'];
|
||||
const openrouterKey = readKey(openrouter);
|
||||
if (openrouterKey) {
|
||||
this.#providers['openrouter'] = new OpenRouterProvider(
|
||||
{
|
||||
apiKey: openrouterKey,
|
||||
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
}
|
||||
|
||||
// Fake provider — always available for testing
|
||||
this.#providers['fake-chat'] = new FakeChatProvider();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Alibaba provider.
|
||||
*
|
||||
* Uses `qwen-turbo` (cheapest generally-available model). Skipped
|
||||
* when `PUTER_TEST_AI_ALIBABA_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'AlibabaProvider (integration)',
|
||||
() => {
|
||||
it('returns a non-empty completion from qwen-turbo', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new AlibabaProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-turbo',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } }).message
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,720 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for AlibabaProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs AlibabaProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. Alibaba is OpenAI-compatible so the OpenAI SDK is mocked at
|
||||
* the module boundary; that's the real network egress point. The
|
||||
* companion integration test (AlibabaProvider.integration.test.ts)
|
||||
* exercises the real DashScope endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { createMock, openAICtor } = vi.hoisted(() => {
|
||||
const createMock = vi.fn();
|
||||
const openAICtor = vi.fn();
|
||||
return { createMock, openAICtor };
|
||||
});
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
opts: unknown,
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.chat = { completions: { create: createMock } };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
});
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = (config?: { apiKey?: string; apiBaseUrl?: string }) => {
|
||||
const provider = new AlibabaProvider(
|
||||
{ apiKey: 'test-key', ...config },
|
||||
server.services.metering,
|
||||
);
|
||||
return { provider };
|
||||
};
|
||||
|
||||
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const makeCapturingChatStream = () => {
|
||||
const chunks: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
});
|
||||
const chatStream = new AIChatStream({ stream: sink });
|
||||
return {
|
||||
chatStream,
|
||||
events: () =>
|
||||
chunks
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider construction', () => {
|
||||
it('points the OpenAI SDK at the DashScope base URL with the configured key', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledTimes(1);
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL:
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a custom base URL when configured', () => {
|
||||
makeProvider({ apiBaseUrl: 'https://custom.endpoint/v1' });
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://custom.endpoint/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider model catalog', () => {
|
||||
it('returns qwen-plus-latest as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
|
||||
});
|
||||
|
||||
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.models()).toBe(ALIBABA_MODELS);
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
for (const m of ALIBABA_MODELS) {
|
||||
expect(ids).toContain(m.id);
|
||||
for (const a of m.aliases ?? []) {
|
||||
expect(ids).toContain(a);
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('qwen-plus');
|
||||
expect(ids).toContain('qwen/qwen-plus');
|
||||
expect(ids).toContain('qwen-max');
|
||||
expect(ids).toContain('qwen/qwen-max');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete request shape', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('forwards model + messages and defaults max_tokens to 1000 when caller omits it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('qwen-plus');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('respects an explicit max_tokens override', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].max_tokens).toBe(256);
|
||||
});
|
||||
|
||||
it('forwards temperature when supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it('omits the `tools` key entirely when no tools are supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect('tools' in args).toBe(false);
|
||||
});
|
||||
|
||||
it('passes tool definitions through unchanged when supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].tools).toBe(tools);
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
);
|
||||
const [nonStreamArgs] = createMock.mock.calls[0]!;
|
||||
expect(nonStreamArgs.stream).toBe(false);
|
||||
expect('stream_options' in nonStreamArgs).toBe(false);
|
||||
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const [streamArgs] = createMock.mock.calls[1]!;
|
||||
expect(streamArgs.stream).toBe(true);
|
||||
expect(streamArgs.stream_options).toEqual({ include_usage: true });
|
||||
});
|
||||
|
||||
it('hoists Puter-style tool_use blocks into OpenAI tool_calls before sending', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
input: { q: 'puter' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.messages[0].content).toBeNull();
|
||||
expect(args.messages[0].tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: JSON.stringify({ q: 'puter' }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model resolution ────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider model resolution', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('resolves an exact canonical id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-max',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('qwen-max');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'alibaba:qwen-max',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an alias to its canonical id (alias rewriting)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen/qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('qwen-plus');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'alibaba:qwen-plus',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion ───────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete non-stream output', () => {
|
||||
it('returns the first choice and runs the metered usage calculator', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi there', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 50 },
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
message: { content: 'hi there', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('alibaba:qwen-plus');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(qwenPlus.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(qwenPlus.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"q":"puter"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'lookup', parameters: {} },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)) as { message: { tool_calls?: unknown[] }; finish_reason: string };
|
||||
|
||||
expect(result.finish_reason).toBe('tool_calls');
|
||||
expect(result.message.tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"puter"}' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 7, completion_tokens: 3 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.cached_tokens).toBe(0);
|
||||
expect(overrides).toMatchObject({ cached_tokens: 0 });
|
||||
});
|
||||
|
||||
it('accounts for cached_tokens when prompt_tokens_details is present', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 20,
|
||||
prompt_tokens_details: { cached_tokens: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen3.6-max-preview',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const model = ALIBABA_MODELS.find(
|
||||
(m) => m.id === 'qwen3.6-max-preview',
|
||||
)!;
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 20,
|
||||
cached_tokens: 15,
|
||||
});
|
||||
expect(prefix).toBe('alibaba:qwen3.6-max-preview');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
|
||||
completion_tokens: 20 * Number(model.costs.completion_tokens),
|
||||
cached_tokens: 15 * Number(model.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete streaming', () => {
|
||||
it('streams text deltas through to text events and meters final usage', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'hel' } }] },
|
||||
{ choices: [{ delta: { content: 'lo' } }] },
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 4, completion_tokens: 2 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect((result as { stream: boolean }).stream).toBe(true);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const events = harness.events();
|
||||
const textEvents = events.filter((e) => e.type === 'text');
|
||||
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('alibaba:qwen-plus');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(qwenPlus.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(qwenPlus.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a tool_use block from streamed function-call deltas', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"q":',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '"puter"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'lookup', parameters: {} },
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const events = harness.events();
|
||||
const toolEvent = events.find((e) => e.type === 'tool_use');
|
||||
expect(toolEvent).toBeDefined();
|
||||
expect(toolEvent?.id).toBe('call_1');
|
||||
expect(toolEvent?.name).toBe('lookup');
|
||||
expect(toolEvent?.input).toEqual({ q: 'puter' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error mapping ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete error mapping', () => {
|
||||
it('rethrows errors raised by the OpenAI client unchanged', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const apiError = new Error('DashScope exploded');
|
||||
createMock.mockRejectedValueOnce(apiError);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(apiError);
|
||||
|
||||
expect(recordSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.checkModeration', () => {
|
||||
it('throws — Alibaba provider does not implement moderation', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(() => provider.checkModeration('anything')).toThrow(
|
||||
/not implemented/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
|
||||
type AlibabaConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
export class AlibabaProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
|
||||
#meteringService: MeteringService;
|
||||
|
||||
constructor(config: AlibabaConfig, meteringService: MeteringService) {
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL:
|
||||
config.apiBaseUrl ??
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'qwen-plus-latest';
|
||||
}
|
||||
|
||||
models() {
|
||||
return ALIBABA_MODELS;
|
||||
}
|
||||
|
||||
async list() {
|
||||
const models = this.models();
|
||||
const modelNames: string[] = [];
|
||||
for (const model of models) {
|
||||
modelNames.push(model.id);
|
||||
if (model.aliases) {
|
||||
modelNames.push(...model.aliases);
|
||||
}
|
||||
}
|
||||
return modelNames;
|
||||
}
|
||||
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
const actor = Context.get('actor');
|
||||
const availableModels = this.models();
|
||||
const modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const completion = await this.#openai.chat.completions.create({
|
||||
messages,
|
||||
model: modelUsed.id,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens: max_tokens || 1000,
|
||||
temperature,
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
} as ChatCompletionCreateParams);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
`alibaba:${modelUsed.id}`,
|
||||
costsOverride,
|
||||
);
|
||||
return trackedUsage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
checkModeration(_text: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -145,9 +145,9 @@ describe('DeepSeekProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider model catalog', () => {
|
||||
it('returns deepseek-chat as the default', () => {
|
||||
it('returns deepseek-v4-flash as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-chat');
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
|
||||
});
|
||||
|
||||
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
|
||||
@@ -164,8 +164,10 @@ describe('DeepSeekProvider model catalog', () => {
|
||||
expect(ids).toContain(a);
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('deepseek-v4-flash');
|
||||
expect(ids).toContain('deepseek-v4-pro');
|
||||
expect(ids).toContain('deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-v4-pro');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,13 +190,13 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('deepseek-chat');
|
||||
expect(args.model).toBe('deepseek-v4-flash');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
});
|
||||
@@ -205,7 +207,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
@@ -220,7 +222,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -244,7 +246,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
@@ -258,7 +260,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
@@ -270,7 +272,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -286,7 +288,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -327,7 +329,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -364,7 +366,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'do tool call' },
|
||||
{
|
||||
@@ -412,16 +414,16 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-reasoner',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-reasoner');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-reasoner',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -432,17 +434,17 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// `deepseek/deepseek-chat` is an alias of `deepseek-chat`.
|
||||
model: 'deepseek/deepseek-chat',
|
||||
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
|
||||
model: 'deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-chat',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -458,11 +460,41 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-chat',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
|
||||
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
|
||||
// so callers using the old names get transparently upgraded — and
|
||||
// metered against the v4-flash canonical prefix.
|
||||
it.each([
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: alias,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -489,7 +521,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -504,8 +536,8 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
|
||||
// deepseek-chat costs: prompt=56, completion=168, cached=0.
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
|
||||
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
@@ -514,7 +546,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('deepseek:deepseek-chat');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(chat.costs.completion_tokens),
|
||||
@@ -549,7 +581,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -584,7 +616,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -617,7 +649,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -642,10 +674,10 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
cached_tokens: 1,
|
||||
});
|
||||
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('deepseek:deepseek-chat');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(chat.costs.completion_tokens),
|
||||
@@ -698,7 +730,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -737,7 +769,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -40,7 +40,7 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'deepseek-chat';
|
||||
return 'deepseek-v4-flash';
|
||||
}
|
||||
|
||||
models() {
|
||||
@@ -137,20 +137,18 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
actor!,
|
||||
`deepseek:${modelUsed.id}`,
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
return trackedUsage;
|
||||
},
|
||||
stream,
|
||||
stream: stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
checkModeration(
|
||||
_text: string,
|
||||
): ReturnType<IChatProvider['checkModeration']> {
|
||||
checkModeration(_text: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
@@ -22,47 +22,56 @@ import type { IChatModel } from '../../types.js';
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-chat',
|
||||
id: 'deepseek-chat',
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-flash',
|
||||
id: 'deepseek-v4-flash',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-12-26',
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
aliases: ['deepseek/deepseek-chat'],
|
||||
context: 128000,
|
||||
aliases: [
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 56,
|
||||
completion_tokens: 168,
|
||||
cached_tokens: 0,
|
||||
prompt_tokens: 14,
|
||||
completion_tokens: 28,
|
||||
cached_tokens: 0.28,
|
||||
},
|
||||
max_tokens: 8000,
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-reasoner',
|
||||
id: 'deepseek-reasoner',
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-pro',
|
||||
id: 'deepseek-v4-pro',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2025-01-20',
|
||||
name: 'DeepSeek Reasoner',
|
||||
aliases: ['deepseek/deepseek-reasoner'],
|
||||
context: 128000,
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 56,
|
||||
completion_tokens: 168,
|
||||
cached_tokens: 0,
|
||||
prompt_tokens: 174,
|
||||
completion_tokens: 348,
|
||||
cached_tokens: 1.45,
|
||||
},
|
||||
max_tokens: 64000,
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
];
|
||||
@@ -20,7 +20,7 @@
|
||||
/**
|
||||
* Integration test for the Mistral provider.
|
||||
*
|
||||
* Uses `mistral-small-2506` (provider default, cheapest tier). Skipped
|
||||
* Uses `mistral-small-2603` (provider default, cheapest tier). Skipped
|
||||
* when `PUTER_TEST_AI_MISTRAL_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
|
||||
@@ -149,9 +149,9 @@ describe('MistralAIProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('MistralAIProvider model catalog', () => {
|
||||
it('returns mistral-small-2506 as the default', () => {
|
||||
it('returns the configured small model as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('mistral-small-2506');
|
||||
expect(provider.getDefaultModel()).toBe('mistral-small-2603');
|
||||
});
|
||||
|
||||
it('exposes the static MISTRAL_MODELS list verbatim from models()', async () => {
|
||||
@@ -171,7 +171,7 @@ describe('MistralAIProvider model catalog', () => {
|
||||
}
|
||||
// Sanity: a known alias is present alongside its canonical id.
|
||||
expect(ids).toContain('mistral-small-latest');
|
||||
expect(ids).toContain('mistral-small-2506');
|
||||
expect(ids).toContain('mistral-small-2603');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 256,
|
||||
temperature: 0.4,
|
||||
@@ -202,7 +202,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = completeMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('mistral-small-2506');
|
||||
expect(args.model).toBe('mistral-small-2603');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
// Mistral's SDK uses maxTokens (camelCase). The provider should
|
||||
// adapt our snake_case input.
|
||||
@@ -216,7 +216,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -240,7 +240,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
@@ -257,7 +257,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -308,7 +308,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
completeMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
@@ -320,7 +320,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
streamMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -370,17 +370,17 @@ describe('MistralAIProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// `mistral-small-latest` is an alias of `mistral-small-2506`.
|
||||
// `mistral-small-latest` is an alias of `mistral-small-2603`.
|
||||
model: 'mistral-small-latest',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'mistral:mistral-small-2506',
|
||||
'mistral:mistral-small-2603',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -396,11 +396,11 @@ describe('MistralAIProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'mistral:mistral-small-2506',
|
||||
'mistral:mistral-small-2603',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -424,7 +424,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -450,12 +450,12 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('mistral:mistral-small-2506');
|
||||
// mistral-small-2506 costs: prompt=10, completion=30. cached_tokens
|
||||
expect(prefix).toBe('mistral:mistral-small-2603');
|
||||
// mistral-small-2603 costs: prompt=15, completion=60. cached_tokens
|
||||
// is undefined in the model row → multiplied by 0 → NaN-safe 0.
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 100 * 10,
|
||||
completion_tokens: 50 * 30,
|
||||
prompt_tokens: 100 * 15,
|
||||
completion_tokens: 50 * 60,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,7 +486,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -535,7 +535,7 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -560,14 +560,14 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
// mistral-small-2506: prompt=10, completion=30.
|
||||
// mistral-small-2603: prompt=15, completion=60.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('mistral:mistral-small-2506');
|
||||
expect(prefix).toBe('mistral:mistral-small-2603');
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 4 * 10,
|
||||
completion_tokens: 2 * 30,
|
||||
prompt_tokens: 4 * 15,
|
||||
completion_tokens: 2 * 60,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -626,7 +626,7 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -666,7 +666,7 @@ describe('MistralAIProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -21,12 +21,12 @@ import { Mistral } from '@mistralai/mistralai';
|
||||
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
} from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { MISTRAL_MODELS } from './models.js';
|
||||
|
||||
export class MistralAIProvider implements IChatProvider {
|
||||
@@ -42,7 +42,7 @@ export class MistralAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'mistral-small-2506';
|
||||
return 'mistral-small-2603';
|
||||
}
|
||||
|
||||
async models() {
|
||||
|
||||
@@ -19,8 +19,37 @@
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
// Hardcoded from https://models.dev/api.json and https://docs.mistral.ai/models/overview
|
||||
export const MISTRAL_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-medium-3-5',
|
||||
id: 'mistral-medium-3-5',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-29',
|
||||
name: 'Mistral Medium 3.5',
|
||||
aliases: [
|
||||
'mistral-medium-3',
|
||||
'mistral-medium-2604',
|
||||
'mistralai/mistral-medium-3-5',
|
||||
'mistralai/mistral-medium-2604',
|
||||
],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'State-of-the-art multimodal model optimized for agentic and coding tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-medium-2508',
|
||||
id: 'mistral-medium-2508',
|
||||
@@ -29,106 +58,82 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-08-12',
|
||||
name: 'mistral-medium-2508',
|
||||
name: 'Mistral Medium 3.1',
|
||||
aliases: [
|
||||
'mistral-medium-latest',
|
||||
'mistral-medium',
|
||||
'mistralai/mistral-medium-2508',
|
||||
'mistral-medium-2508',
|
||||
'mistral-medium-latest',
|
||||
'mistralai/mistral-medium-latest',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Update on Mistral Medium 3 with improved capabilities.',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description: 'Frontier-class multimodal model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-7b',
|
||||
id: 'open-mistral-7b',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2023-12',
|
||||
release_date: '2023-09-27',
|
||||
name: 'open-mistral-7b',
|
||||
aliases: [
|
||||
'mistral-tiny',
|
||||
'mistral-tiny-2312',
|
||||
'mistralai/open-mistral-7b',
|
||||
],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'Our first dense model released September 2023.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-nemo',
|
||||
id: 'open-mistral-nemo',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-07-01',
|
||||
name: 'open-mistral-nemo',
|
||||
aliases: [
|
||||
'open-mistral-nemo-2407',
|
||||
'mistral-tiny-2407',
|
||||
'mistral-tiny-latest',
|
||||
'mistralai/open-mistral-nemo',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our best multilingual open source model released July 2024.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/pixtral-large-2411',
|
||||
id: 'pixtral-large-2411',
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-11',
|
||||
release_date: '2024-11-01',
|
||||
name: 'pixtral-large-2411',
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-02',
|
||||
name: 'Mistral Large 3',
|
||||
aliases: [
|
||||
'pixtral-large-latest',
|
||||
'mistral-large-pixtral-2411',
|
||||
'mistralai/pixtral-large-2411',
|
||||
'mistral-large-latest',
|
||||
'mistral-large',
|
||||
'mistralai/mistral-large-latest',
|
||||
'mistralai/mistral-large-2512',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Official pixtral-large-2411 Mistral AI model',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'State-of-the-art, open-weight, general-purpose multimodal model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 600,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-small-2603',
|
||||
id: 'mistral-small-2603',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2026-03-16',
|
||||
name: 'Mistral Small 4',
|
||||
aliases: [
|
||||
'mistral-small-latest',
|
||||
'mistral-small',
|
||||
'mistralai/mistral-small-latest',
|
||||
'mistralai/mistral-small-2603',
|
||||
],
|
||||
context: 256_000,
|
||||
max_tokens: 256_000,
|
||||
description:
|
||||
'Hybrid open-weight model for instruct, reasoning, and coding tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -137,116 +142,73 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-05-29',
|
||||
name: 'codestral-2508',
|
||||
knowledge: '2025-08',
|
||||
release_date: '2025-08-29',
|
||||
name: 'Codestral',
|
||||
aliases: ['codestral-latest', 'mistralai/codestral-2508'],
|
||||
context: 256000,
|
||||
max_tokens: 256000,
|
||||
context: 256_000,
|
||||
max_tokens: 256_000,
|
||||
description:
|
||||
'Our cutting-edge language model for coding released August 2025.',
|
||||
'Cutting-edge language model for code completion, proficient in over 80 programming languages.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 90,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/devstral-small-2507',
|
||||
id: 'devstral-small-2507',
|
||||
puterId: 'mistralai:mistralai/devstral-2512',
|
||||
id: 'devstral-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-07-10',
|
||||
name: 'devstral-small-2507',
|
||||
aliases: ['devstral-small-latest', 'mistralai/devstral-small-2507'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our small open-source code-agentic model.',
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-09',
|
||||
name: 'Devstral 2',
|
||||
aliases: [
|
||||
'devstral-latest',
|
||||
'mistralai/devstral-latest',
|
||||
'mistralai/devstral-2512',
|
||||
'devstral-medium-latest',
|
||||
'mistralai/devstral-medium-latest',
|
||||
],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Frontier open-source code agents model for software engineering.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/devstral-medium-2507',
|
||||
id: 'devstral-medium-2507',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-07-10',
|
||||
name: 'devstral-medium-2507',
|
||||
aliases: ['devstral-medium-latest', 'mistralai/devstral-medium-2507'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our medium code-agentic model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 200,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-small-2506',
|
||||
id: 'mistral-small-2506',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-03',
|
||||
release_date: '2025-06-20',
|
||||
name: 'mistral-small-2506',
|
||||
aliases: ['mistral-small-latest', 'mistralai/mistral-small-2506'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our latest enterprise-grade small model with the latest version released June 2025.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/magistral-medium-2509',
|
||||
id: 'magistral-medium-2509',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2025-03-17',
|
||||
name: 'magistral-medium-2509',
|
||||
release_date: '2025-09-01',
|
||||
name: 'Magistral Medium 1.2',
|
||||
aliases: ['magistral-medium-latest', 'mistralai/magistral-medium-2509'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our frontier-class reasoning model release candidate September 2025.',
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Frontier-class multimodal reasoning model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 500,
|
||||
},
|
||||
@@ -258,143 +220,149 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2025-03-17',
|
||||
name: 'magistral-small-2509',
|
||||
release_date: '2025-09-01',
|
||||
name: 'Magistral Small 1.2',
|
||||
aliases: ['magistral-small-latest', 'mistralai/magistral-small-2509'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our efficient reasoning model released September 2025.',
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Efficient open-weight reasoning model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-mini-2507',
|
||||
id: 'voxtral-mini-2507',
|
||||
name: 'voxtral-mini-2507',
|
||||
aliases: ['voxtral-mini-latest', 'mistralai/voxtral-mini-2507'],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'A mini audio understanding model released in July 2025',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-small-2507',
|
||||
id: 'voxtral-small-2507',
|
||||
name: 'voxtral-small-2507',
|
||||
aliases: ['voxtral-small-latest', 'mistralai/voxtral-small-2507'],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'A small audio understanding model released in July 2025',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-latest',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-11',
|
||||
release_date: '2024-11-01',
|
||||
name: 'mistral-large-2512',
|
||||
aliases: ['mistral-large-2512', 'mistralai/mistral-large-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Official mistral-large-2512 Mistral AI model',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-3b-2512',
|
||||
id: 'ministral-3b-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-10-01',
|
||||
name: 'ministral-3b-2512',
|
||||
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 3B Instruct.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-8b-2512',
|
||||
id: 'ministral-8b-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-10-01',
|
||||
name: 'ministral-8b-2512',
|
||||
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 8B Instruct.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-14b-2512',
|
||||
id: 'ministral-14b-2512',
|
||||
name: 'ministral-14b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 14B',
|
||||
aliases: ['ministral-14b-latest', 'mistralai/ministral-14b-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 14B Instruct.',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Best-in-class compact model with text and vision capabilities.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
completion_tokens: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-8b-2512',
|
||||
id: 'ministral-8b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 8B',
|
||||
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Efficient compact model with text and vision capabilities.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-3b-2512',
|
||||
id: 'ministral-3b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 3B',
|
||||
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Tiny, efficient model with multimodal support.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-nemo-2407',
|
||||
id: 'open-mistral-nemo-2407',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-07-25',
|
||||
name: 'Mistral Nemo 12B',
|
||||
aliases: [
|
||||
'open-mistral-nemo',
|
||||
'mistral-nemo',
|
||||
'mistral-nemo-latest',
|
||||
'mistralai/open-mistral-nemo',
|
||||
'mistralai/open-mistral-nemo-2407',
|
||||
'mistralai/mistral-nemo',
|
||||
],
|
||||
context: 128_000,
|
||||
max_tokens: 128_000,
|
||||
description: 'Best multilingual open source model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-small-2507',
|
||||
id: 'voxtral-small-2507',
|
||||
modalities: { input: ['text', 'audio'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-07',
|
||||
release_date: '2025-07-15',
|
||||
name: 'Voxtral Small',
|
||||
aliases: [
|
||||
'voxtral-small-latest',
|
||||
'mistralai/voxtral-small-2507',
|
||||
'mistralai/voxtral-small-latest',
|
||||
'voxtral-small-latest',
|
||||
],
|
||||
context: 32_768,
|
||||
max_tokens: 32_768,
|
||||
description: 'Audio-input model for instruct tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -87,6 +87,69 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
context: 1_050_000,
|
||||
max_tokens: 1_050_000,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-pro',
|
||||
id: 'gpt-5.4-pro',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
release_date: '2026-03-05',
|
||||
aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 3000,
|
||||
completion_tokens: 18000,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-mini',
|
||||
id: 'gpt-5.4-mini',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
cached_tokens: 7.5,
|
||||
completion_tokens: 450,
|
||||
},
|
||||
context: 400_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-nano',
|
||||
id: 'gpt-5.4-nano',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
release_date: '2026-03-19',
|
||||
aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
cached_tokens: 2,
|
||||
completion_tokens: 125,
|
||||
},
|
||||
context: 400_000,
|
||||
max_tokens: 128_000,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.3-codex',
|
||||
id: 'gpt-5.3-codex',
|
||||
|
||||
@@ -384,20 +384,10 @@ export const create_chat_stream_handler_responses_api =
|
||||
chatStream.end(usage);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown } params.usage_calculator
|
||||
* @returns
|
||||
*/
|
||||
export const handle_completion_output = async ({
|
||||
deviations,
|
||||
stream,
|
||||
completion,
|
||||
moderate,
|
||||
usage_calculator,
|
||||
finally_fn,
|
||||
}) => {
|
||||
export const handle_completion_output = async (
|
||||
/** @type {Record<string,unknown> & {usage_calculator:(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown }}*/
|
||||
{ deviations, stream, completion, moderate, usage_calculator, finally_fn },
|
||||
) => {
|
||||
deviations = Object.assign(
|
||||
{
|
||||
// affected by: Mistral
|
||||
|
||||
@@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── puter_output_path ─────────────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
const TEST_ACTOR: import('../../core/actor.js').Actor = {
|
||||
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
|
||||
};
|
||||
|
||||
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
|
||||
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
|
||||
|
||||
it('throws 400 when puter_output_path resolves to root', async () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 when puter_output_path parent is root (e.g. /image.png)', async () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/image.png',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 403 when ACL denies write access to the destination', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/somedir/image.png',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ACL check runs BEFORE provider.generate so credits are not wasted on a denied path', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockImplementation(async () => {
|
||||
callOrder.push('acl');
|
||||
return false;
|
||||
});
|
||||
openaiImagesGenerateMock.mockImplementation(async () => {
|
||||
callOrder.push('provider');
|
||||
return { data: [{ url: 'https://oai/img.png' }] };
|
||||
});
|
||||
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
expect(callOrder).toEqual(['acl']);
|
||||
});
|
||||
|
||||
it('resolves ~ in puter_output_path to /<username>/', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(Buffer.from('fake-png'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '~/images/out.png',
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
|
||||
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
|
||||
expect(
|
||||
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
|
||||
).toBe('/testuser/images/out.png');
|
||||
});
|
||||
|
||||
it('writes the generated image to FS and still returns the result URL', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(Buffer.from('fake-png'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/photos/out.png',
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://oai/img.png');
|
||||
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
|
||||
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
|
||||
expect(userId).toBe(42);
|
||||
const meta = (
|
||||
writeArg as {
|
||||
fileMetadata: {
|
||||
path: string;
|
||||
contentType: string;
|
||||
overwrite: boolean;
|
||||
};
|
||||
}
|
||||
).fileMetadata;
|
||||
expect(meta.path).toBe('/testuser/photos/out.png');
|
||||
expect(meta.contentType).toBe('image/png');
|
||||
expect(meta.overwrite).toBe(true);
|
||||
});
|
||||
|
||||
it('does not forward puter_output_path to the upstream provider call', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(Buffer.from('fake-png'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.puter_output_path).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
|
||||
const noIdActor: import('../../core/actor.js').Actor = {
|
||||
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
|
||||
};
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext({ actor: noIdActor }, () =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/noone/dir/img.png',
|
||||
} as never),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Avoid coupling the 'unused' XAI export to lint. The catalog reference
|
||||
// is also used implicitly by the routing tests above.
|
||||
void XAI_IMAGE_GENERATION_MODELS;
|
||||
@@ -18,8 +18,12 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
|
||||
@@ -114,12 +118,34 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async generate(args: IGenerateParams): Promise<string> {
|
||||
const actor = Context.get('actor');
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
|
||||
const puterOutputPath = args.puter_output_path;
|
||||
delete args.puter_output_path;
|
||||
|
||||
// Validate the output path early — before spending credits.
|
||||
let resolvedOutputPath: string | undefined;
|
||||
if (puterOutputPath) {
|
||||
const username = actor.user?.username;
|
||||
const userId = actor.user?.id;
|
||||
if (!userId || !username) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'User ID required for puter_output_path',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
resolvedOutputPath = this.#resolveOutputPath(
|
||||
puterOutputPath,
|
||||
username,
|
||||
);
|
||||
await this.#assertWriteAccess(actor, resolvedOutputPath);
|
||||
}
|
||||
|
||||
let modelId = args.model?.trim().toLowerCase();
|
||||
let intendedProvider =
|
||||
args.provider ?? (Context.get('driverName') as string | undefined);
|
||||
@@ -172,11 +198,17 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
{},
|
||||
);
|
||||
|
||||
return provider.generate({
|
||||
const result = await provider.generate({
|
||||
...args,
|
||||
model: model.id,
|
||||
provider: model.provider,
|
||||
});
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
await this.#saveToFS(actor, result, resolvedOutputPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#normalizeRatio(parameters: IGenerateParams) {
|
||||
@@ -340,6 +372,106 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async #saveToFS(
|
||||
actor: Actor,
|
||||
result: string,
|
||||
resolvedPath: string,
|
||||
): Promise<void> {
|
||||
const userId = actor.user!.id!;
|
||||
|
||||
let buffer: Buffer;
|
||||
let contentType: string;
|
||||
|
||||
if (result.startsWith('data:')) {
|
||||
const commaIdx = result.indexOf(',');
|
||||
const header = result.substring(0, commaIdx);
|
||||
contentType =
|
||||
header.match(/data:(.*?);/)?.[1] ?? 'application/octet-stream';
|
||||
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
|
||||
} else {
|
||||
const response = await fetch(result);
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Failed to fetch generated image for FS write: ${response.status}`,
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
}
|
||||
contentType =
|
||||
response.headers.get('content-type') ??
|
||||
'application/octet-stream';
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
await this.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: resolvedPath,
|
||||
size: buffer.length,
|
||||
contentType,
|
||||
overwrite: true,
|
||||
createMissingParents: true,
|
||||
},
|
||||
fileContent: Readable.from(buffer),
|
||||
});
|
||||
}
|
||||
|
||||
#resolveOutputPath(outputPath: string, username: string): string {
|
||||
let resolved = outputPath.trim();
|
||||
if (resolved === '~' || resolved.startsWith('~/')) {
|
||||
resolved = `/${username}${resolved.slice(1)}`;
|
||||
}
|
||||
assertNormalized(resolved);
|
||||
if (!resolved.startsWith('/')) {
|
||||
resolved = `/${resolved}`;
|
||||
}
|
||||
if (resolved.length > 1 && resolved.endsWith('/')) {
|
||||
resolved = resolved.slice(0, -1);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #assertWriteAccess(
|
||||
actor: Actor,
|
||||
resolvedPath: string,
|
||||
): Promise<void> {
|
||||
if (resolvedPath === '/') {
|
||||
throw new HttpError(400, 'Cannot write to root path', {
|
||||
legacyCode: 'cannot_write_to_root',
|
||||
});
|
||||
}
|
||||
const parentPath = pathPosix.dirname(resolvedPath);
|
||||
if (parentPath === '/') {
|
||||
throw new HttpError(400, 'Cannot write to root path', {
|
||||
legacyCode: 'cannot_write_to_root',
|
||||
});
|
||||
}
|
||||
|
||||
const pathToCheck = parentPath;
|
||||
const fsService = this.services.fs;
|
||||
let ancestorsCache: Promise<
|
||||
Array<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
const canWrite = await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path: pathToCheck,
|
||||
resolveAncestors() {
|
||||
if (!ancestorsCache) {
|
||||
ancestorsCache =
|
||||
fsService.getAncestorChain(pathToCheck);
|
||||
}
|
||||
return ancestorsCache;
|
||||
},
|
||||
},
|
||||
'write',
|
||||
);
|
||||
if (!canWrite) {
|
||||
throw new HttpError(403, 'Write access denied for destination', {
|
||||
legacyCode: 'access_denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#resolveModel(modelId: string, provider?: string): IImageModel | null {
|
||||
const models = this.#modelIdMap[modelId];
|
||||
if (!models || models.length === 0) return null;
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface IGenerateParams {
|
||||
input_image?: string;
|
||||
input_image_mime_type?: string;
|
||||
input_images?: string[];
|
||||
puter_output_path?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export class SpeechToTextDriver extends PuterDriver {
|
||||
this.services.fs,
|
||||
actor,
|
||||
args.file,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE },
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
|
||||
);
|
||||
|
||||
const selectedModel =
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('XAITTSProvider catalog', () => {
|
||||
expect(engines[0]).toMatchObject({
|
||||
id: 'xai-tts',
|
||||
provider: 'xai',
|
||||
pricing_per_million_chars: 420,
|
||||
pricing_per_million_chars: 1500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export class XAITTSProvider extends TTSProvider {
|
||||
id: 'xai-tts',
|
||||
name: 'xAI TTS',
|
||||
provider: 'xai',
|
||||
pricing_per_million_chars: 420,
|
||||
pricing_per_million_chars: 1500,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,18 +17,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// xAI TTS pricing: $4.20 per 1M characters
|
||||
// = 420 cents per 1M characters
|
||||
// = 0.00042 cents per character
|
||||
// = 0.42 microcents per character
|
||||
// We store microcents per character (cents * 1_000_000 / 1_000_000 chars).
|
||||
// $4.20 / 1M chars = 420 cents / 1M chars = 0.00042 cents/char = 0.42 ucents/char
|
||||
// But metering uses integer microcents, so we round up to 1 microcent per char
|
||||
// to avoid undercharging. More precisely: 420 * 1_000_000 / 1_000_000 = 420 ucents per char? No.
|
||||
// Let's be precise:
|
||||
// $4.20 per 1M chars = 420 cents per 1M chars
|
||||
// In microcents: 420 * 1_000_000 = 420_000_000 microcents per 1M chars
|
||||
// Per character: 420_000_000 / 1_000_000 = 420 microcents per character
|
||||
// xAI TTS pricing (per xAI docs, Voice pricing table): $15.00 per 1M characters
|
||||
// $15.00 per 1M chars = 1500 cents per 1M chars
|
||||
// In microcents: 1500 * 1_000_000 = 1_500_000_000 microcents per 1M chars
|
||||
// Per character: 1_500_000_000 / 1_000_000 = 1500 microcents per character
|
||||
export const XAI_TTS_COSTS: Record<string, number> = {
|
||||
'xai-tts': 420,
|
||||
'xai-tts': 1500,
|
||||
};
|
||||
@@ -448,3 +448,186 @@ describe('VideoGenerationDriver metering propagation', () => {
|
||||
expect(usageType).toMatch(/^openai:sora-2:/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── puter_output_path ─────────────────────────────────────────────
|
||||
|
||||
describe('VideoGenerationDriver.generate puter_output_path', () => {
|
||||
const TEST_ACTOR: import('../../core/actor.js').Actor = {
|
||||
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
|
||||
};
|
||||
|
||||
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
|
||||
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
|
||||
|
||||
it('throws 400 when puter_output_path is root', async () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 when puter_output_path parent is root (e.g. /video.mp4)', async () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/video.mp4',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 403 when ACL denies write access', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/testuser/videos/clip.mp4',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ACL check runs BEFORE provider.generate so credits are not wasted', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockImplementation(async () => {
|
||||
callOrder.push('acl');
|
||||
return false;
|
||||
});
|
||||
openaiVideosCreateMock.mockImplementation(async () => {
|
||||
callOrder.push('provider');
|
||||
return openaiCompletedJob();
|
||||
});
|
||||
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/testuser/dir/clip.mp4',
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
expect(callOrder).toEqual(['acl']);
|
||||
});
|
||||
|
||||
it('resolves ~ in puter_output_path to /<username>/', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
|
||||
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '~/videos/clip.mp4',
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
|
||||
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
|
||||
expect(
|
||||
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
|
||||
).toBe('/testuser/videos/clip.mp4');
|
||||
});
|
||||
|
||||
it('writes stream result to FS and returns a new stream to caller', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
|
||||
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
|
||||
|
||||
const result = await withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/testuser/videos/clip.mp4',
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
|
||||
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
|
||||
expect(userId).toBe(42);
|
||||
const meta = (
|
||||
writeArg as {
|
||||
fileMetadata: {
|
||||
path: string;
|
||||
contentType: string;
|
||||
overwrite: boolean;
|
||||
};
|
||||
}
|
||||
).fileMetadata;
|
||||
expect(meta.path).toBe('/testuser/videos/clip.mp4');
|
||||
expect(meta.overwrite).toBe(true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not forward puter_output_path to the upstream provider call', async () => {
|
||||
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
|
||||
aclCheckSpy.mockResolvedValueOnce(true);
|
||||
|
||||
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
|
||||
fsWriteSpy.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
|
||||
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/testuser/dir/clip.mp4',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const sent = openaiVideosCreateMock.mock.calls[0]![0];
|
||||
expect(sent.puter_output_path).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
|
||||
const noIdActor: import('../../core/actor.js').Actor = {
|
||||
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
|
||||
};
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext({ actor: noIdActor }, () =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
puter_output_path: '/noone/dir/clip.mp4',
|
||||
} as never),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
|
||||
@@ -114,12 +118,34 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async generate(args: IGenerateVideoParams) {
|
||||
const actor = Context.get('actor');
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
|
||||
const puterOutputPath = args.puter_output_path;
|
||||
delete args.puter_output_path;
|
||||
|
||||
// Validate the output path early — before spending credits.
|
||||
let resolvedOutputPath: string | undefined;
|
||||
if (puterOutputPath) {
|
||||
const username = actor.user?.username;
|
||||
const userId = actor.user?.id;
|
||||
if (!userId || !username) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'User ID required for puter_output_path',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
resolvedOutputPath = this.#resolveOutputPath(
|
||||
puterOutputPath,
|
||||
username,
|
||||
);
|
||||
await this.#assertWriteAccess(actor, resolvedOutputPath);
|
||||
}
|
||||
|
||||
if (args.model) {
|
||||
args.model = args.model.trim().toLowerCase();
|
||||
}
|
||||
@@ -202,11 +228,17 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
args.resolution = normalizedResolution;
|
||||
}
|
||||
|
||||
return await provider.generate({
|
||||
const result = await provider.generate({
|
||||
...args,
|
||||
model: model.id,
|
||||
provider: model.provider,
|
||||
});
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
return await this.#saveToFS(actor, result, resolvedOutputPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -- Provider registration -----------------------------------------------
|
||||
@@ -350,6 +382,135 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async #saveToFS(
|
||||
actor: Actor,
|
||||
result: unknown,
|
||||
resolvedPath: string,
|
||||
): Promise<unknown> {
|
||||
const userId = actor.user!.id!;
|
||||
|
||||
let buffer: Buffer;
|
||||
let contentType: string;
|
||||
|
||||
if (typeof result === 'string') {
|
||||
if (result.startsWith('data:')) {
|
||||
const commaIdx = result.indexOf(',');
|
||||
const header = result.substring(0, commaIdx);
|
||||
contentType = header.match(/data:(.*?);/)?.[1] ?? 'video/mp4';
|
||||
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
|
||||
} else {
|
||||
const response = await fetch(result);
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Failed to fetch generated video for FS write: ${response.status}`,
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
}
|
||||
contentType =
|
||||
response.headers.get('content-type') ?? 'video/mp4';
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
} else if (result && typeof result === 'object' && 'stream' in result) {
|
||||
const streamResult = result as {
|
||||
stream: Readable;
|
||||
content_type: string;
|
||||
};
|
||||
contentType = streamResult.content_type || 'video/mp4';
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of streamResult.stream) {
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
);
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else {
|
||||
throw new HttpError(
|
||||
500,
|
||||
'Unsupported video result format for puter_output_path',
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
}
|
||||
|
||||
await this.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: resolvedPath,
|
||||
size: buffer.length,
|
||||
contentType,
|
||||
overwrite: true,
|
||||
createMissingParents: true,
|
||||
},
|
||||
fileContent: Readable.from(buffer),
|
||||
});
|
||||
|
||||
// For stream results, reconstruct a new stream from the buffered data
|
||||
if (typeof result !== 'string') {
|
||||
return {
|
||||
stream: Readable.from(buffer),
|
||||
content_type: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#resolveOutputPath(outputPath: string, username: string): string {
|
||||
let resolved = outputPath.trim();
|
||||
if (resolved === '~' || resolved.startsWith('~/')) {
|
||||
resolved = `/${username}${resolved.slice(1)}`;
|
||||
}
|
||||
assertNormalized(resolved);
|
||||
if (!resolved.startsWith('/')) {
|
||||
resolved = `/${resolved}`;
|
||||
}
|
||||
if (resolved.length > 1 && resolved.endsWith('/')) {
|
||||
resolved = resolved.slice(0, -1);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #assertWriteAccess(
|
||||
actor: Actor,
|
||||
resolvedPath: string,
|
||||
): Promise<void> {
|
||||
if (resolvedPath === '/') {
|
||||
throw new HttpError(400, 'Cannot write to root path', {
|
||||
legacyCode: 'cannot_write_to_root',
|
||||
});
|
||||
}
|
||||
const parentPath = pathPosix.dirname(resolvedPath);
|
||||
if (parentPath === '/') {
|
||||
throw new HttpError(400, 'Cannot write to root path', {
|
||||
legacyCode: 'cannot_write_to_root',
|
||||
});
|
||||
}
|
||||
|
||||
const pathToCheck = parentPath;
|
||||
const fsService = this.services.fs;
|
||||
let ancestorsCache: Promise<
|
||||
Array<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
const canWrite = await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path: pathToCheck,
|
||||
resolveAncestors() {
|
||||
if (!ancestorsCache) {
|
||||
ancestorsCache =
|
||||
fsService.getAncestorChain(pathToCheck);
|
||||
}
|
||||
return ancestorsCache;
|
||||
},
|
||||
},
|
||||
'write',
|
||||
);
|
||||
if (!canWrite) {
|
||||
throw new HttpError(403, 'Write access denied for destination', {
|
||||
legacyCode: 'access_denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#resolveModel(modelId: string, provider?: string): IVideoModel | null {
|
||||
const models = this.#modelIdMap[modelId?.trim().toLowerCase()];
|
||||
if (!models || models.length === 0) return null;
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface IGenerateVideoParams {
|
||||
metadata?: object;
|
||||
input_reference?: unknown;
|
||||
no_extra_params?: boolean;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface IVideoProvider {
|
||||
|
||||
@@ -64,13 +64,13 @@ const meta = (
|
||||
describe('KVStoreDriver — rate-limit policy', () => {
|
||||
const m = meta(new KVStoreDriver(...fake()));
|
||||
|
||||
it('pins the kv tier values (registered 200 / 10s, temp 100 / 10s)', () => {
|
||||
it('pins the kv tier values (registered 400 / 10s, temp 200 / 10s)', () => {
|
||||
expect(m.rateLimit?.default).toEqual({
|
||||
limit: 200,
|
||||
limit: 400,
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 200,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 400,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -98,13 +98,13 @@ describe('AppDriver — rate-limit policy', () => {
|
||||
describe('SubdomainDriver — rate-limit policy', () => {
|
||||
const m = meta(new SubdomainDriver(...fake()));
|
||||
|
||||
it('matches AppDriver tier values (subdomain CRUD is similarly cheap)', () => {
|
||||
it('pins the subdomain tier values (registered 200 / 10s, temp 100 / 10s)', () => {
|
||||
expect(m.rateLimit?.default).toEqual({
|
||||
limit: 100,
|
||||
limit: 200,
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 100,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 200,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,11 +51,11 @@ export class KVStoreDriver extends PuterDriver {
|
||||
// (no subscription resolution) is not given the tighter cap.
|
||||
readonly rateLimit: DriverRateLimitConfig = {
|
||||
default: {
|
||||
limit: 200,
|
||||
limit: 400,
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 200,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 400,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,11 +81,11 @@ export class SubdomainDriver extends PuterDriver {
|
||||
// same shape — the three crud-q drivers share one envelope.
|
||||
readonly rateLimit: DriverRateLimitConfig = {
|
||||
default: {
|
||||
limit: 100,
|
||||
limit: 200,
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 100,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 200,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,11 +37,11 @@ import { AI_CONCURRENT, AI_RATE_LIMIT } from './aiLimits.js';
|
||||
describe('AI_RATE_LIMIT', () => {
|
||||
it('pins the documented tier values', () => {
|
||||
expect(AI_RATE_LIMIT.default).toEqual({
|
||||
limit: 100,
|
||||
limit: 200,
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 30,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 20,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 60,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 40,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -75,10 +75,10 @@ describe('AI_RATE_LIMIT', () => {
|
||||
describe('AI_CONCURRENT', () => {
|
||||
it('pins the documented tier values', () => {
|
||||
expect(AI_CONCURRENT.default).toEqual({
|
||||
limit: 10,
|
||||
limit: 20,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 3,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 6,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,21 +42,21 @@ import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
|
||||
export const AI_RATE_LIMIT: DriverRateLimitConfig = {
|
||||
default: {
|
||||
limit: 100, // subscribed / paid tier
|
||||
limit: 200, // subscribed / paid tier
|
||||
window: 10_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 30, // verified registered user
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 20, // temp / anonymous-email user
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 60, // verified registered user
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 40, // temp / anonymous-email user
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AI_CONCURRENT: DriverConcurrentConfig = {
|
||||
default: {
|
||||
limit: 10, // subscribed / paid tier
|
||||
limit: 20, // subscribed / paid tier
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 3, // verified registered user
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 2, // temp / anonymous-email user
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 6, // verified registered user
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 4, // temp / anonymous-email user
|
||||
},
|
||||
},
|
||||
};
|
||||
+18
-12
@@ -37,6 +37,7 @@ import {
|
||||
allowedAppIdsGate,
|
||||
requireAuthGate,
|
||||
requireEmailConfirmedGate,
|
||||
requireNonAccessTokenGate,
|
||||
requireUserActorGate,
|
||||
requireVerifiedGate,
|
||||
subdomainGate,
|
||||
@@ -368,13 +369,10 @@ export class PuterServer {
|
||||
* `#materializeRoute` as those options ship.
|
||||
*/
|
||||
#installGlobalMiddleware() {
|
||||
// ── Cookie parsing ──────────────────────────────────────────
|
||||
this.#app.use(cookieParser());
|
||||
|
||||
// ── Compression ─────────────────────────────────────────────
|
||||
this.#app.use(compression());
|
||||
|
||||
// ── Security headers (helmet) ───────────────────────────────
|
||||
this.#app.use(helmet.noSniff());
|
||||
this.#app.use(helmet.hsts());
|
||||
this.#app.use(helmet.ieNoOpen());
|
||||
@@ -615,7 +613,6 @@ export class PuterServer {
|
||||
this.#app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const subdomain = req.subdomains?.[req.subdomains.length - 1];
|
||||
const isApiOrDav = subdomain === 'api' || subdomain === 'dav';
|
||||
|
||||
// Allow any origin. puter.js is meant to be consumed from
|
||||
// arbitrary third-party sites, so reflect the caller's origin
|
||||
@@ -623,11 +620,11 @@ export class PuterServer {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
||||
if (origin) res.vary('Origin');
|
||||
|
||||
// Credentials require a specific (non-`*`) Allow-Origin, which
|
||||
// we just set when an origin was present. Enable on API/DAV
|
||||
// so cookie-auth works cross-origin.
|
||||
if (isApiOrDav && origin) {
|
||||
// Sticky cookies require api to allow credentials, but only for the API subdomain, and be careful not to set any other credentials on it
|
||||
if (subdomain === 'api' && origin) {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
} else if (subdomain === 'dav') {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
|
||||
@@ -795,7 +792,9 @@ export class PuterServer {
|
||||
opts.allowedAppIds ||
|
||||
opts.requireVerified,
|
||||
);
|
||||
if (needsAuth) mwChain.push(requireAuthGate());
|
||||
if (needsAuth) {
|
||||
mwChain.push(requireAuthGate());
|
||||
}
|
||||
|
||||
// Default-on email confirmation gate. Every authenticated route
|
||||
// rejects users pending confirmation unless `allowUnconfirmed`
|
||||
@@ -806,6 +805,11 @@ export class PuterServer {
|
||||
mwChain.push(requireEmailConfirmedGate());
|
||||
}
|
||||
|
||||
// block access tokens by default
|
||||
if (needsAuth && !opts.allowAccessToken) {
|
||||
mwChain.push(requireNonAccessTokenGate());
|
||||
}
|
||||
|
||||
// `requireVerified` intentionally does NOT imply `requireUserActor`:
|
||||
// FS routes (and similar) want the user's email to be confirmed even
|
||||
// when an app acts on the user's behalf. `requireVerifiedGate` reads
|
||||
@@ -817,7 +821,9 @@ export class PuterServer {
|
||||
// token, not only from browser sessions. `adminOnlyGate` gates on
|
||||
// `actor.user.username`, which is populated for access-token and
|
||||
// app-under-user actors alike.
|
||||
if (opts.requireUserActor) mwChain.push(requireUserActorGate());
|
||||
if (opts.requireUserActor) {
|
||||
mwChain.push(requireUserActorGate());
|
||||
}
|
||||
|
||||
if (opts.adminOnly) {
|
||||
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
|
||||
@@ -942,9 +948,9 @@ export class PuterServer {
|
||||
};
|
||||
}
|
||||
if (fullPath !== undefined) {
|
||||
app.use(fullPath as any, ...mwChain, handler);
|
||||
app.use(fullPath as any, ...mwChain.flat(), handler);
|
||||
} else {
|
||||
app.use(...mwChain, handler);
|
||||
app.use(...mwChain.flat(), handler);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class ACLService extends PuterService {
|
||||
if (resource.path === '/') {
|
||||
return (PUBLIC_READ_MODES as AclMode[]).includes(mode);
|
||||
}
|
||||
const ancestors = await resource.resolveAncestors();
|
||||
|
||||
const components = resource.path.slice(1).split('/');
|
||||
|
||||
@@ -171,7 +172,6 @@ export class ACLService extends PuterService {
|
||||
const authorizer = actor.accessToken.issuer;
|
||||
if (!(await this.check(authorizer, resource, mode))) return false;
|
||||
|
||||
const ancestors = await resource.resolveAncestors();
|
||||
for (const ancestor of ancestors) {
|
||||
const permissions =
|
||||
mode === MANAGE_PERM_PREFIX
|
||||
@@ -219,7 +219,6 @@ export class ACLService extends PuterService {
|
||||
// Fall back to the permission scan: walk ancestors, any hit wins.
|
||||
// Widen the scan to all "higher" modes (`write` covers `read`/`list`/
|
||||
// `see`, etc.) so granting a stronger mode implies the weaker ones.
|
||||
const ancestors = await resource.resolveAncestors();
|
||||
for (const ancestor of ancestors) {
|
||||
const permissions =
|
||||
mode === MANAGE_PERM_PREFIX
|
||||
|
||||
@@ -911,10 +911,12 @@ export class AuthService extends PuterService {
|
||||
// ── Actor builders ──────────────────────────────────────────────
|
||||
|
||||
#actorUserFromRow(user: UserRow) {
|
||||
// Strip the password hash; pass everything else through so callers
|
||||
// can read metadata, desktop_bg_*, otp_enabled, etc. without
|
||||
// re-fetching the user. Mirrors what /whoami exposes off of UserRow.
|
||||
const { password: _password, ...rest } = user;
|
||||
return {
|
||||
uuid: user.uuid,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
...rest,
|
||||
email: user.email ?? null,
|
||||
suspended: user.suspended ?? false,
|
||||
email_confirmed: user.email_confirmed ?? false,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from './resolveNode.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import type { TransformCallback } from 'node:stream';
|
||||
@@ -268,7 +269,8 @@ export class FSService extends PuterService {
|
||||
);
|
||||
}
|
||||
|
||||
let normalizedPath = pathPosix.normalize(trimmedPath);
|
||||
assertNormalized(trimmedPath);
|
||||
let normalizedPath = trimmedPath;
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,15 @@ export function splitParentAndName(absolutePath: string): {
|
||||
return { parentPath: parentPath === '.' ? '/' : parentPath, name };
|
||||
}
|
||||
|
||||
export function assertNormalized(input: string): string {
|
||||
if (pathPosix.normalize(input) !== input) {
|
||||
throw new HttpError(400, 'Invalid path', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function normalizeAbsolutePath(path: string): string {
|
||||
const trimmed = typeof path === 'string' ? path.trim() : '';
|
||||
if (trimmed.length === 0) {
|
||||
@@ -137,7 +146,8 @@ export function normalizeAbsolutePath(path: string): string {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
let normalized = pathPosix.normalize(trimmed);
|
||||
assertNormalized(trimmed);
|
||||
let normalized = trimmed;
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { createAdapter } from '@socket.io/redis-streams-adapter';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { Server as SocketIOServer, type Socket } from 'socket.io';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isAppActor, isAccessTokenActor } from '../../core/actor.js';
|
||||
import { PuterService } from '../types.js';
|
||||
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
/**
|
||||
* Socket push target. A `room` fans to every socket in that room; a
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { LayerInstances } from '../../types.js';
|
||||
@@ -175,7 +176,8 @@ export class FSEntryStore extends PuterStore {
|
||||
});
|
||||
}
|
||||
|
||||
let normalized = pathPosix.normalize(trimmed);
|
||||
assertNormalized(trimmed);
|
||||
let normalized = trimmed;
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
@@ -419,6 +419,25 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
}
|
||||
|
||||
async unconfirmOthersByEmail(
|
||||
userId: number,
|
||||
email: string,
|
||||
cleanEmailValue: string,
|
||||
): Promise<void> {
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`user\`
|
||||
SET \`email\` = NULL,
|
||||
\`clean_email\` = NULL,
|
||||
\`email_confirmed\` = 0,
|
||||
\`requires_email_confirmation\` = 0,
|
||||
\`email_confirm_code\` = NULL,
|
||||
\`email_confirm_token\` = NULL
|
||||
WHERE \`id\` != ?
|
||||
AND (\`email\` = ? OR \`clean_email\` = ?)`,
|
||||
[userId, email, cleanEmailValue],
|
||||
);
|
||||
}
|
||||
|
||||
async invalidate(user: UserRow): Promise<void> {
|
||||
const keys = this.#cacheKeysForUser(user);
|
||||
await this.publishCacheKeys({ keys, broadcast: true });
|
||||
|
||||
@@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the
|
||||
| `provider` | `String` | The AI provider to use. `'openai-image-generation' (default) \| 'gemini' \| 'together' \| 'xai' \| 'replicate-image-generation'` |
|
||||
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-2-image'` when `provider: 'xai'` |
|
||||
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
|
||||
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
|
||||
|
||||
#### OpenAI Options
|
||||
|
||||
@@ -129,6 +130,18 @@ For more details, see the [Replicate API reference](https://replicate.com/docs)
|
||||
|
||||
Any properties not set fall back to provider defaults.
|
||||
|
||||
#### Saving to Puter filesystem
|
||||
|
||||
Pass `puter_output_path` to persist the generated image directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
|
||||
|
||||
```js
|
||||
puter.ai.txt2img("A sunset over the mountains", {
|
||||
puter_output_path: "images/sunset.png" // saved to ~/AppData/<appID>/images/sunset.png
|
||||
});
|
||||
```
|
||||
|
||||
Absolute paths (`/username/Pictures/sunset.png`) and home-relative paths (`~/Pictures/sunset.png`) are sent as-is. Write permission to the destination is enforced server-side.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points at a data URL containing the image.
|
||||
|
||||
@@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the
|
||||
| `model` | `String` | Video model to use (provider-specific). Defaults to `'sora-2'` |
|
||||
| `seconds` | `Number` | Target clip length in seconds |
|
||||
| `test_mode` | `Boolean` | When `true`, returns a sample video without using credits |
|
||||
| `puter_output_path` | `String` | When set, the generated video is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
|
||||
|
||||
#### OpenAI Options
|
||||
|
||||
@@ -87,6 +88,18 @@ For more details about each option, see the [TogetherAI API reference](https://d
|
||||
|
||||
Any properties not set fall back to provider defaults.
|
||||
|
||||
#### Saving to Puter filesystem
|
||||
|
||||
Pass `puter_output_path` to persist the generated video directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
|
||||
|
||||
```js
|
||||
puter.ai.txt2vid("A drone shot over a forest", {
|
||||
puter_output_path: "videos/forest.mp4" // saved to ~/AppData/<appID>/videos/forest.mp4
|
||||
});
|
||||
```
|
||||
|
||||
Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that resolves to an `HTMLVideoElement`. The element is preloaded, has `controls` enabled, and exposes metadata via `data-mime-type` and `data-source` attributes. Append it to the DOM to display the generated clip immediately.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
Puter.js brings free, serverless, Cloud and AI directly to your frontend JavaScript with no backend code or API keys required. Use the `@heyputer/puter.js` npm module or drop in a single `<script>` tag to instantly access file storage, databases, Claude, GPT, Gemini, and more right from your frontend code.
|
||||
|
||||
Puter.js is also uniquely suited for use by AI coding assistants, agents, and vibe coding platforms like Claude Code, Codex, OpenCode, Lovable, Replit, and more. Because Puter.js is keyless and serverless, AI-generated apps and programs using it work end-to-end without having to sign up for a third-party backend, provision a service, or paste in API keys. This eliminates both a major class of security issues and the setup friction that usually keeps these apps from running out of the box.
|
||||
|
||||
<div class="browser-window">
|
||||
<div class="titlebar">
|
||||
<div class="buttons">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -44,7 +44,6 @@ import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
|
||||
// Import tab modules
|
||||
import TabHome from './TabHome.js';
|
||||
import TabFiles from './TabFiles.js';
|
||||
import TabApps from './TabApps.js';
|
||||
import TabUsage from './TabUsage.js';
|
||||
import TabAccount from './TabAccount.js';
|
||||
@@ -54,7 +53,6 @@ import TabSecurity from './TabSecurity.js';
|
||||
const builtinTabs = [
|
||||
TabHome,
|
||||
TabApps,
|
||||
TabFiles,
|
||||
'-',
|
||||
TabUsage,
|
||||
TabAccount,
|
||||
@@ -156,11 +154,6 @@ async function UIDashboard (options) {
|
||||
$el_window.find('.dashboard-sidebar').addClass('collapsed');
|
||||
}
|
||||
|
||||
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
|
||||
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
|
||||
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
|
||||
}
|
||||
|
||||
// Initialize all tabs
|
||||
for ( const tab of tabs ) {
|
||||
if ( tab.init ) {
|
||||
@@ -232,97 +225,6 @@ async function UIDashboard (options) {
|
||||
}
|
||||
});
|
||||
|
||||
// Trash status updates
|
||||
window.socket.on('trash.is_empty', async (msg) => {
|
||||
// Update sidebar Trash icon
|
||||
const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
|
||||
$('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon);
|
||||
|
||||
// If currently viewing trash and it's empty, clear the file list
|
||||
const dashboard = window.dashboard_object;
|
||||
if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) {
|
||||
$('.files-tab .files').empty();
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Item event handlers
|
||||
// Incremental DOM updates using UIDashboardFileItem for item creation and
|
||||
// direct jQuery manipulation for removals/updates. Mirrors UIDesktop's
|
||||
// approach but adapted for Dashboard's list-view structure.
|
||||
// =========================================================================
|
||||
|
||||
window.socket.on('item.moved', async (resp) => {
|
||||
if ( resp.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
// Fade out old item from view
|
||||
$(`.item[data-uid='${resp.uid}']`).fadeOut(150, function () {
|
||||
$(this).remove();
|
||||
});
|
||||
|
||||
// Create new item at destination if user is viewing that directory
|
||||
if ( window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(resp);
|
||||
}
|
||||
});
|
||||
|
||||
window.socket.on('item.removed', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
if ( item.descendants_only ) return;
|
||||
|
||||
$(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () {
|
||||
$(this).remove();
|
||||
});
|
||||
});
|
||||
|
||||
window.socket.on('item.renamed', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
const $el = $(`.item[data-uid='${item.uid}']`);
|
||||
if ( $el.length === 0 ) return;
|
||||
|
||||
// Update data attributes
|
||||
$el.attr('data-name', html_encode(item.name));
|
||||
$el.attr('data-path', html_encode(item.path));
|
||||
|
||||
// Update displayed name
|
||||
$el.find('.item-name').text(item.name);
|
||||
$el.find('.item-name-editor').val(item.name);
|
||||
});
|
||||
|
||||
window.socket.on('item.updated', async (item) => {
|
||||
const $el = $(`.item[data-uid='${item.uid}']`);
|
||||
if ( $el.length === 0 ) return;
|
||||
|
||||
// Update data attributes
|
||||
$el.attr('data-name', html_encode(item.name));
|
||||
$el.attr('data-path', html_encode(item.path));
|
||||
$el.attr('data-size', item.size);
|
||||
$el.attr('data-modified', item.modified);
|
||||
$el.attr('data-type', html_encode(item.type));
|
||||
|
||||
// Update displayed name
|
||||
$el.find('.item-name').text(item.name);
|
||||
$el.find('.item-name-editor').val(item.name);
|
||||
|
||||
if (
|
||||
window.dashboard_object?.currentView === 'grid'
|
||||
&& typeof item.thumbnail === 'string'
|
||||
&& item.thumbnail.length > 0
|
||||
) {
|
||||
$el.find('.item-icon img').attr('src', item.thumbnail);
|
||||
}
|
||||
});
|
||||
|
||||
window.socket.on('item.added', async (item) => {
|
||||
if ( !item || Object.keys(item).length === 0 ) return;
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
if ( window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply initial route from URL - activate the correct tab
|
||||
if ( window.dashboard_initial_route ) {
|
||||
const route = window.dashboard_initial_route;
|
||||
@@ -356,7 +258,6 @@ async function UIDashboard (options) {
|
||||
const handleRouteChange = () => {
|
||||
const route = window.parseDashboardRoute();
|
||||
const tab = route.tab;
|
||||
const filePath = route.path;
|
||||
|
||||
// Switch to correct tab
|
||||
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
|
||||
@@ -378,14 +279,6 @@ async function UIDashboard (options) {
|
||||
|
||||
// Scroll content area to top
|
||||
$el_window.find('.dashboard-content').scrollTop(0);
|
||||
|
||||
// If files tab with path, navigate without adding to history
|
||||
if ( tab === 'files' && filePath ) {
|
||||
const filesTab = tabs.find(t => t.id === 'files');
|
||||
if ( filesTab?.renderDirectory ) {
|
||||
filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for both hashchange and popstate to handle all navigation scenarios
|
||||
|
||||
@@ -124,7 +124,9 @@ async function UIWindowFinalizeUserDeletion (options) {
|
||||
return fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -274,8 +274,9 @@ async function UIWindowLogin (options) {
|
||||
let h = '';
|
||||
h += '<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:center; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">';
|
||||
// logo
|
||||
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
h += '<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">';
|
||||
h += `<img src="${window.icons['logo-white.svg']}" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 15px; background-color: blue; border-radius: 5px;">`;
|
||||
h += `<img src="${window.icons['logo-white.svg']}" class="auth-logo" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 15px; background-color: blue; border-radius: 5px;${logo_clickable ? ' cursor: pointer;' : ''}">`;
|
||||
h += '</div>';
|
||||
// title
|
||||
h += '<div style="padding:10px 20px; text-align:center; margin-bottom:0;">';
|
||||
@@ -297,7 +298,10 @@ async function UIWindowLogin (options) {
|
||||
h += '</div>';
|
||||
// password
|
||||
h += '<div style="position: relative; margin-bottom: 20px;">';
|
||||
h += `<label style="display:block; margin-bottom:5px;">${i18n('password')}</label>`;
|
||||
h += '<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">';
|
||||
h += `<label style="margin-bottom:0;">${i18n('password')}</label>`;
|
||||
h += `<span class="forgot-password-link" style="font-size:13px;">${i18n('forgot_pass_c2a')}</span>`;
|
||||
h += '</div>';
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="current-password"/>`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
@@ -306,14 +310,12 @@ async function UIWindowLogin (options) {
|
||||
h += '</div>';
|
||||
// login
|
||||
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
|
||||
// password recovery
|
||||
h += `<p style="text-align:center; margin-bottom: 0;"><span class="forgot-password-link">${i18n('forgot_pass_c2a')}</span></p>`;
|
||||
// OIDC
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 0 0 10px 0;">';
|
||||
h += `<div style="text-align:center; margin: 10px 0; font-size:13px; color:var(--color-text-muted);">${ i18n('or') }</div>`;
|
||||
h += `<div style="display:flex; align-items:center; gap:10px; margin: 10px 0; font-size:13px; color:#888;"><div style="flex:1; height:1px; background-color:#d0d0d0;"></div><span>${ i18n('or') }</span><div style="flex:1; height:1px; background-color:#d0d0d0;"></div></div>`;
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_in_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
@@ -377,6 +379,12 @@ async function UIWindowLogin (options) {
|
||||
},
|
||||
});
|
||||
|
||||
if ( logo_clickable ) {
|
||||
$(el_window).find('.auth-logo').on('click', function () {
|
||||
window.location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
$(el_window).find('.forgot-password-link').on('click', function (e) {
|
||||
UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
|
||||
@@ -32,10 +32,12 @@ function UIWindowSignup (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
|
||||
let h = '';
|
||||
h += '<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">';
|
||||
// logo
|
||||
h += `<img src="${window.icons['logo-white.svg']}" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 10px; background-color: blue; border-radius: 5px;">`;
|
||||
h += `<img src="${window.icons['logo-white.svg']}" class="auth-logo" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 10px; background-color: blue; border-radius: 5px;${logo_clickable ? ' cursor: pointer;' : ''}">`;
|
||||
// close button
|
||||
if ( !options.has_head && options.show_close_button !== false )
|
||||
{
|
||||
@@ -96,8 +98,8 @@ function UIWindowSignup (options) {
|
||||
h += '</form>';
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
|
||||
h += '</div>';
|
||||
// "Sign up with email" button, shown only when OIDC providers are available
|
||||
h += `<div class="signup-with-email-wrapper" style="display:none;">`;
|
||||
@@ -142,6 +144,11 @@ function UIWindowSignup (options) {
|
||||
{
|
||||
$(el_window).find('.username').get(0).focus({ preventScroll: true });
|
||||
}
|
||||
if ( logo_clickable ) {
|
||||
$(el_window).find('.auth-logo').on('click', function () {
|
||||
window.location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Turnstile widget with callback to capture token
|
||||
const initTurnstile = () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1910,19 +1910,6 @@ span.header-sort-icon img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* TabFiles rubber band selection area */
|
||||
.tabfiles-selection-area {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid var(--select-color);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -1735,10 +1735,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
suggested_apps: fsentry.suggested_apps,
|
||||
};
|
||||
UIItem(options);
|
||||
// In dashboard mode, also create item via dashboard's renderer
|
||||
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(fsentry);
|
||||
}
|
||||
moved_items.push({ 'options': options, 'original_path': $(el_item).attr('data-path') });
|
||||
|
||||
// this operation may have created some missing directories,
|
||||
@@ -1764,10 +1760,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
suggested_apps: dir.suggested_apps,
|
||||
});
|
||||
}
|
||||
// In dashboard mode, also create parent dirs via dashboard's renderer
|
||||
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(dir);
|
||||
}
|
||||
window.sort_items(item_container);
|
||||
});
|
||||
|
||||
|
||||
+6
-12
@@ -160,21 +160,15 @@ if ( window.location.pathname === '/dashboard' || window.location.pathname === '
|
||||
|
||||
/**
|
||||
* Parses the dashboard URL hash into a route object.
|
||||
* Hash format: #files/username/Documents or #usage or #account etc.
|
||||
* @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path
|
||||
* Hash format: #usage or #account etc.
|
||||
* @returns {{ tab: string }} Route object with tab name
|
||||
*/
|
||||
function parseDashboardRoute () {
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding
|
||||
if ( ! hash ) return { tab: 'home', path: null };
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1));
|
||||
if ( ! hash ) return { tab: 'home' };
|
||||
|
||||
const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']
|
||||
const tab = parts[0]; // 'files', 'usage', 'account', 'security'
|
||||
|
||||
if ( tab === 'files' && parts.length > 1 ) {
|
||||
const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents
|
||||
return { tab: 'files', path: filePath };
|
||||
}
|
||||
return { tab: tab || 'home', path: null };
|
||||
const tab = hash.split('/').filter(Boolean)[0];
|
||||
return { tab: tab || 'home' };
|
||||
}
|
||||
|
||||
// Make parseDashboardRoute available globally for hashchange handler
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Testing puter.js
|
||||
|
||||
This document covers the automated end-to-end test suite for `puter.js` UI APIs (`setMenubar`, `contextMenu`, etc.). The suite uses Playwright to drive a real Puter desktop and exercise the APIs the way real users hit them — including layout interaction bugs that pure-JS unit tests can't see.
|
||||
|
||||
The suite started as a regression net and is structured to grow incrementally as more `puter.ui` methods are covered.
|
||||
|
||||
## Quick start (first run on a new machine)
|
||||
|
||||
1. **Start Puter desktop** (monorepo root):
|
||||
```sh
|
||||
cd /path/to/puter
|
||||
npm start
|
||||
```
|
||||
On first launch this prints a credentials block:
|
||||
```
|
||||
************************************************************
|
||||
* Your default login credentials are:
|
||||
* Username: admin
|
||||
* Password: <hex string>
|
||||
************************************************************
|
||||
```
|
||||
Copy the password — it's stable across restarts.
|
||||
|
||||
2. **Start the puter-js dev server** (serves `dist/puter.dev.js` and the test fixture on `localhost:8080`):
|
||||
```sh
|
||||
cd /path/to/puter/src/puter-js
|
||||
npm start
|
||||
```
|
||||
Wait for webpack's "compiled successfully".
|
||||
|
||||
3. **Install Playwright browsers** (one-time):
|
||||
```sh
|
||||
cd /path/to/puter/src/puter-js
|
||||
npm install
|
||||
npm run playwright:install
|
||||
```
|
||||
|
||||
4. **Set the admin password** (one-time, see [Setting the password](#setting-the-password) below for the recommended `.env` approach).
|
||||
|
||||
5. **Run the tests**:
|
||||
```sh
|
||||
cd /path/to/puter/src/puter-js
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Setting the password
|
||||
|
||||
The admin password (from step 1) is the only credential the test suite needs. **Never hardcode it.** Two ways to provide it, pick one:
|
||||
|
||||
### Option A: `.env` file (recommended)
|
||||
|
||||
The `tests/e2e/.env` file is gitignored, so credentials never leave your machine.
|
||||
|
||||
```sh
|
||||
cd /path/to/puter/src/puter-js
|
||||
cp tests/e2e/.env.example tests/e2e/.env
|
||||
```
|
||||
|
||||
Edit `tests/e2e/.env` and set the password you copied from step 1:
|
||||
|
||||
```
|
||||
PUTER_ADMIN_PASSWORD=<your password>
|
||||
```
|
||||
|
||||
That's it. All `npm run test:e2e*` commands pick it up automatically via `globalSetup`.
|
||||
|
||||
### Option B: shell environment variable
|
||||
|
||||
```sh
|
||||
export PUTER_ADMIN_PASSWORD=<your password>
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Add the `export` to your shell rc (`~/.zshrc`, `~/.bashrc`) to persist across sessions. Shell env always wins over `.env`.
|
||||
|
||||
### When the password changes
|
||||
|
||||
The admin password is stored in Puter's KV under `tmp_password` and survives restarts, so it changes rarely (typically only if you wipe the local Puter database). When it does change, update your `.env` and force a fresh login:
|
||||
|
||||
```sh
|
||||
PUTER_TEST_RESET_AUTH=1 npm run test:e2e
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
All commands run from `src/puter-js`:
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `npm run test:e2e` | Headless run, both projects (`chromium` + `mobile-chromium`). |
|
||||
| `npm run test:e2e:headed` | Same, but watches the browser drive Puter desktop — useful for debugging selectors. |
|
||||
| `npm run test:e2e:ui` | Playwright UI mode (timeline scrubber, retry single test, picker for selectors). |
|
||||
| `npm run test:e2e:record` | Records video, trace, and screenshots for **every** test (passing or failing). Files land in `test-results/<spec>/`. |
|
||||
| `npm run test:e2e:report` | Opens the HTML report from the last run. |
|
||||
| `npx playwright test --project=mobile-chromium` | Run only the mobile project (the contextMenu z-index regression test lives here). |
|
||||
|
||||
By default, video and trace are saved **only on failure**. `test:e2e:record` enables them for everything.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Playwright (chromium / mobile-chromium) │
|
||||
│ ↓ navigates to │
|
||||
│ http://puter.localhost:4100/app/puter-js-testing-<uuid> │
|
||||
│ │
|
||||
│ ┌─── Puter desktop (renders menus, contextMenus) ──────┐ │
|
||||
│ │ ┌─── App iframe (the fixture) ──────────────────┐ │ │
|
||||
│ │ │ loads dist/puter.dev.js │ │ │
|
||||
│ │ │ calls puter.ui.setMenubar({...}) │ │ │
|
||||
│ │ │ buttons trigger puter.ui.contextMenu({...}) │ │ │
|
||||
│ │ │ logs interactions to <div id="log"> │ │ │
|
||||
│ │ └───────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Authentication (once per run, cached for 24h)
|
||||
|
||||
`globalSetup` runs at the start of the test run:
|
||||
|
||||
1. Direct `POST /login` from Node with the admin credentials → JWT token.
|
||||
2. Probes `window.api_origin` from Puter (server-templated) so we can pass it through.
|
||||
3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=<token>&api_origin=<origin>`. Puter's `initgui` auth_token handler runs the full auth setup: `puter.setAuthToken`, `puter.setAPIOrigin`, `/session/sync-cookie`, `update_auth_data`.
|
||||
4. Saves cookies + localStorage to `tests/e2e/.auth/state.json` (gitignored, cached for 24h).
|
||||
|
||||
Every test context loads with that `storageState` → already signed in as admin → no per-test `/login`, no `/signup`, no rate limits.
|
||||
|
||||
### Per-test flow
|
||||
|
||||
For each test:
|
||||
|
||||
1. **Test's `page` fixture** opens with the cached storageState — already authenticated.
|
||||
2. **`registerTestApp(page)`** verifies the fixture URL is reachable, navigates to `puter.localhost:4100/`, waits for the SDK to settle, and calls `puter.apps.create('puter-js-testing-<uuid>', '<fixture URL>')`.
|
||||
3. **`gotoTestApp(page, name)`** navigates to `puter.localhost:4100/app/<name>`. Puter desktop's `/app/<name>` handler calls `puter.apps.get(name)` → `launch_app(...)` → opens a window with an iframe at the fixture URL (with `?puter.app_instance_id=...` so puter.js detects `env=app`). Waits for `body.ready` inside the iframe.
|
||||
4. **Test body**: clicks fixture buttons inside the iframe → fixture calls `puter.ui.setMenubar(...)` / `puter.ui.contextMenu(...)`. puter.js postMessages to Puter desktop → desktop renders `.window-menubar` / `.context-menu` in the **parent** DOM. Playwright asserts on those parent-frame elements and clicks items. Each click fires the item's `action` callback **back inside the iframe** (via Puter's RPC hydration), which appends a known string to `<div id="log">`. Assertions check that the right log entry appeared.
|
||||
5. **`deleteTestApp(page, name)`** in `finally` — best-effort cleanup of the ephemeral app.
|
||||
|
||||
### Two viewports, one regression test
|
||||
|
||||
Both projects (`chromium` for desktop, `mobile-chromium` for `iPhone 13`) run the same specs. The mobile contextMenu test is the named regression for commit `aa5e398e`'s z-index bug — if the dismiss-overlay regresses to sit above the menu, the tap never reaches the item, the log stays empty, the test fails.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
src/puter-js/
|
||||
├── playwright.config.js # projects, globalSetup, recording flags
|
||||
├── TESTING.md # this file
|
||||
└── tests/e2e/
|
||||
├── README.md # short pointer, defers here
|
||||
├── .env # YOUR password (gitignored)
|
||||
├── .env.example # committed template, no secrets
|
||||
├── .auth/ # cached auth state (gitignored)
|
||||
├── globalSetup.js # signs in once, saves storageState
|
||||
├── helpers/
|
||||
│ └── testApp.js # registerTestApp / gotoTestApp / deleteTestApp / waitForPuterReady
|
||||
├── fixtures/
|
||||
│ └── menubar-contextmenu.html # the Puter app under test
|
||||
└── specs/
|
||||
├── menubar.spec.js
|
||||
└── contextMenu.spec.js
|
||||
```
|
||||
|
||||
The legacy `test/` (singular) folder is the manual browser harness for `puter.ai` / `puter.fs` / `puter.kv` / `puter.txt2speech` and stays as-is — different purpose, different audience.
|
||||
|
||||
## Adding new tests
|
||||
|
||||
The harness is set up to scale linearly. To cover another `puter.ui` method (e.g. `alert`, `prompt`, `notify`, `showOpenFilePicker`):
|
||||
|
||||
1. Add a button to `tests/e2e/fixtures/menubar-contextmenu.html` (or create a new fixture file) that calls the method with a known spec and logs the response into `<div id="log">`.
|
||||
2. Add a spec under `tests/e2e/specs/` following the same shape as `menubar.spec.js`:
|
||||
- `registerTestApp` → `gotoTestApp` → click fixture button → assert on Puter desktop's rendered DOM in the parent frame → click items / verify response → assert log entry appeared.
|
||||
3. If the method has different desktop/mobile rendering, that's automatically exercised by both projects.
|
||||
|
||||
For methods that don't render UI (like `disableMenuItem`, `setMenuItemChecked`), assert on the resulting state changes in the menubar DOM instead of on log entries.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
All env vars are optional unless marked required.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `PUTER_ADMIN_PASSWORD` | _(required)_ | Local Puter admin password (from `npm start`'s startup banner). Stable across restarts. |
|
||||
| `PUTER_ADMIN_USERNAME` | `admin` | Local Puter admin username. |
|
||||
| `PUTER_TEST_ORIGIN` | `http://puter.localhost:4100` | The Puter desktop origin Playwright drives. |
|
||||
| `PUTER_TEST_FIXTURE_ORIGIN` | `http://localhost:8080` | Where the fixture HTML is served. The Puter app's `indexURL` is set to `${PUTER_TEST_FIXTURE_ORIGIN}/tests/e2e/fixtures/menubar-contextmenu.html`. |
|
||||
| `PUTER_TEST_RECORD` | unset | When `1`, records video for every test (default: only on failure). |
|
||||
| `PUTER_TEST_RESET_AUTH` | unset | When `1`, ignores the cached auth in `tests/e2e/.auth/state.json` and forces a fresh `/login`. Use after a password change or if Puter rejects the cached token. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `PUTER_ADMIN_PASSWORD is not set` | Env var or `.env` missing. | Follow [Setting the password](#setting-the-password). |
|
||||
| `POST /login → HTTP 400: Username not found` | Local Puter hasn't created the admin user yet. | Run `npm start` from monorepo root and wait for the credentials banner. |
|
||||
| `POST /login → HTTP 400: Invalid password.` | Password in your `.env` is wrong. | Re-check the banner, update `.env`, then `PUTER_TEST_RESET_AUTH=1 npm run test:e2e`. |
|
||||
| `POST /login → HTTP 403: Forbidden` | Puter's CSRF check rejected the request. | Should not happen — globalSetup sets the `Origin` header. If it does, confirm `PUTER_TEST_ORIGIN` matches Puter's actual origin. |
|
||||
| `Fixture URL is unreachable` | puter-js dev server isn't running. | From `src/puter-js`: `npm start`, wait for "compiled successfully". |
|
||||
| `puter.apps.create failed: Unauthorized` with `APIOrigin: "https://api.puter.com"` | The cached `storageState` is from before the api_origin fix. | `rm -rf tests/e2e/.auth && npm run test:e2e`. |
|
||||
| Test launches a window but iframe stays blank | puter-js dev server stopped mid-run, or fixture path is wrong. | Restart `npm start` in `src/puter-js` and re-run. |
|
||||
| `body.ready` timeout in `gotoTestApp` | Puter desktop didn't open the app window. Usually means `puter.apps.get(name)` 404'd. | Check the test trace for `puter.apps.create` errors above this one. |
|
||||
| Mobile contextMenu test fails on `expect(log entry).toBeVisible()` | The actual bug we're guarding against — overlay z-index regression on mobile. | Fix the GUI's `.context-menu-sheet-backdrop` z-index (see commit `aa5e398e`). |
|
||||
|
||||
## CI
|
||||
|
||||
Not wired up yet. Local runs depend on Puter being started via `npm start` (which auto-creates the admin user on first launch). CI will need a different bootstrap path — TBD when we get there.
|
||||
@@ -31,12 +31,19 @@
|
||||
"start-webpack": "webpack --stats=errors-only && webpack --output-filename puter.dev.js --watch --devtool source-map --stats=errors-only",
|
||||
"start": "concurrently \"npm run start-server\" \"npm run start-webpack\"",
|
||||
"test": "npx http-server --cors -c-1 -o /test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:record": "PUTER_TEST_RECORD=1 playwright test",
|
||||
"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",
|
||||
"prepublishOnly": "npm run build && mv dist/puter.js dist/puter.cjs && npm version patch"
|
||||
},
|
||||
"author": "Puter Technologies Inc.",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"webpack-cli": "^5.1.4"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
|
||||
const FIXTURE_ORIGIN = process.env.PUTER_TEST_FIXTURE_ORIGIN || 'http://localhost:8080';
|
||||
const RECORD_ALL = !!process.env.PUTER_TEST_RECORD;
|
||||
const STORAGE_STATE_PATH = path.join(__dirname, 'tests', 'e2e', '.auth', 'state.json');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/specs',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
globalSetup: './tests/e2e/globalSetup.js',
|
||||
use: {
|
||||
baseURL: PUTER_ORIGIN,
|
||||
storageState: STORAGE_STATE_PATH,
|
||||
trace: 'retain-on-failure',
|
||||
video: RECORD_ALL ? 'on' : 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'mobile-chromium',
|
||||
use: { ...devices['iPhone 13'] },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
puterOrigin: PUTER_ORIGIN,
|
||||
fixtureOrigin: FIXTURE_ORIGIN,
|
||||
},
|
||||
});
|
||||
@@ -521,7 +521,7 @@ async function driverCall_ (
|
||||
}
|
||||
|
||||
// HTTP Error - unauthorized
|
||||
if ( response.status === 401 || resp?.code === 'token_auth_failed' ) {
|
||||
if ( response.target.status === 401 || resp?.code === 'token_auth_failed' ) {
|
||||
if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) {
|
||||
try {
|
||||
puter.resetAuthToken();
|
||||
@@ -543,7 +543,7 @@ async function driverCall_ (
|
||||
return reject_func({ status: 401, message: 'Unauthorized' });
|
||||
}
|
||||
// HTTP Error - other
|
||||
else if ( response.status && response.status !== 200 ) {
|
||||
else if ( response.target.status && response.target.status !== 200 ) {
|
||||
// if error callback is provided, call it
|
||||
error_cb(resp);
|
||||
// reject promise
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js';
|
||||
|
||||
const normalizeTTSProvider = (value) => {
|
||||
if ( typeof value !== 'string' ) {
|
||||
@@ -910,6 +911,11 @@ class AI {
|
||||
} else {
|
||||
AIService = 'ai-image';
|
||||
}
|
||||
|
||||
if ( options.puter_output_path ) {
|
||||
options.puter_output_path = getAbsolutePathForApp(options.puter_output_path);
|
||||
}
|
||||
|
||||
// Call the original chat.complete method
|
||||
return await utils.make_driver_method(['prompt'], 'puter-image-generation', AIService, 'generate', {
|
||||
responseType: 'blob',
|
||||
@@ -983,6 +989,10 @@ class AI {
|
||||
videoService = driverHint;
|
||||
}
|
||||
|
||||
if ( options.puter_output_path ) {
|
||||
options.puter_output_path = getAbsolutePathForApp(options.puter_output_path);
|
||||
}
|
||||
|
||||
return await utils.make_driver_method(['prompt'], 'puter-video-generation', videoService, 'generate', {
|
||||
responseType: 'blob',
|
||||
test_mode: testMode ?? false,
|
||||
|
||||
@@ -12,9 +12,64 @@ class PuterWebComponent extends (globalThis.HTMLElement || Object) {
|
||||
}
|
||||
|
||||
connectedCallback () {
|
||||
this._setupThemeWatchers();
|
||||
this._applyTheme();
|
||||
this._rerender();
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this._teardownThemeWatchers();
|
||||
}
|
||||
|
||||
_setupThemeWatchers () {
|
||||
if ( typeof globalThis.MutationObserver !== 'undefined' && ! this._themeObserver ) {
|
||||
this._themeObserver = new globalThis.MutationObserver(() => this._applyTheme());
|
||||
this._themeObserver.observe(this, { attributes: true, attributeFilter: ['theme'] });
|
||||
}
|
||||
if ( typeof globalThis.matchMedia === 'function' && ! this._themeMediaQuery ) {
|
||||
this._themeMediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)');
|
||||
this._themeMediaListener = () => this._applyTheme();
|
||||
if ( this._themeMediaQuery.addEventListener ) {
|
||||
this._themeMediaQuery.addEventListener('change', this._themeMediaListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_teardownThemeWatchers () {
|
||||
if ( this._themeObserver ) {
|
||||
this._themeObserver.disconnect();
|
||||
this._themeObserver = null;
|
||||
}
|
||||
if ( this._themeMediaQuery && this._themeMediaListener && this._themeMediaQuery.removeEventListener ) {
|
||||
this._themeMediaQuery.removeEventListener('change', this._themeMediaListener);
|
||||
}
|
||||
this._themeMediaQuery = null;
|
||||
this._themeMediaListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective theme from the `theme` attribute and the system
|
||||
* preference, then toggles a `.puter-theme-dark` class on the host so
|
||||
* components can style with `:host(.puter-theme-dark) ...`.
|
||||
* theme="dark" → always dark
|
||||
* theme="light" → always light
|
||||
* unset / other → follow system (prefers-color-scheme)
|
||||
*/
|
||||
_applyTheme () {
|
||||
if ( typeof this.getAttribute !== 'function' ) return;
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
let isDark;
|
||||
if ( themeAttr === 'dark' ) {
|
||||
isDark = true;
|
||||
} else if ( themeAttr === 'light' ) {
|
||||
isDark = false;
|
||||
} else {
|
||||
isDark = typeof globalThis.matchMedia === 'function'
|
||||
&& globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
this.classList.toggle('puter-theme-dark', isDark);
|
||||
}
|
||||
|
||||
_rerender () {
|
||||
if ( ! this.shadowRoot ) return;
|
||||
this.shadowRoot.innerHTML = `<style>${this.getStyles()}</style>${this.render()}`;
|
||||
|
||||
@@ -340,7 +340,25 @@ All components render inside Shadow DOM and match puter.com's native GUI appeara
|
||||
|
||||
## Dark mode
|
||||
|
||||
Components with `@media (prefers-color-scheme: dark)` rules auto-adapt when the OS is in dark mode: `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
|
||||
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
|
||||
|
||||
To force a specific theme regardless of the OS preference, set the `theme` attribute on the component:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `dark` | Always render in dark theme |
|
||||
| `light` | Always render in light theme |
|
||||
| _unset_ | Follow the system `prefers-color-scheme` (default) |
|
||||
|
||||
```html
|
||||
<!-- Force dark, even in an OS configured for light mode -->
|
||||
<puter-menubar theme="dark"></puter-menubar>
|
||||
|
||||
<!-- Force light, even when the OS is in dark mode -->
|
||||
<puter-notification theme="light" title="Hello"></puter-notification>
|
||||
```
|
||||
|
||||
The attribute is live — changing it at runtime re-paints the component immediately. For `<puter-menubar>` and `<puter-context-menu>`, the `theme` is forwarded to any dropdowns and submenus they spawn, so the whole tree stays in sync.
|
||||
|
||||
## Responsive / mobile
|
||||
|
||||
|
||||
@@ -176,6 +176,46 @@ class PuterColorPicker extends PuterWebComponent {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .picker-body {
|
||||
background-color: rgba(40, 44, 52, .95);
|
||||
color: #e6e6e6;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .preview {
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .header-label {
|
||||
color: #aaa;
|
||||
}
|
||||
:host(.puter-theme-dark) .hex-input {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .native-color-row {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .native-color-row label {
|
||||
color: #aaa;
|
||||
}
|
||||
:host(.puter-theme-dark) input[type="color"] {
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .swatch {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn {
|
||||
color: #e6e6e6;
|
||||
border-color: #555;
|
||||
background: linear-gradient(#4a4a4a, #3a3a3a);
|
||||
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn:active {
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,15 +47,16 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
font-family: sans-serif;
|
||||
background: #FFF;
|
||||
color: #333;
|
||||
border-radius: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 0;
|
||||
min-width: 200px;
|
||||
background-color: rgb(255 255 255 / 92%);
|
||||
backdrop-filter: blur(3px);
|
||||
border: 1px solid #e6e4e466;
|
||||
box-shadow: 0px 0px 15px #00000066;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
box-shadow: 0px 3px 10px #00000044;
|
||||
margin-top: 5px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
user-select: none;
|
||||
@@ -88,35 +89,68 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Active item turns all children white */
|
||||
/* Active item turns all children white.
|
||||
For .has-open-submenu the :hover branch above already covers
|
||||
the hovered case; in the non-hovered grey state we want the
|
||||
children to keep their default colors, so we don't include
|
||||
.has-open-submenu here. */
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .check,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .submenu-arrow,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .shortcut,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .label,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .shortcut,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .check,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .submenu-arrow,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .shortcut,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .label,
|
||||
.menu-item.has-open-submenu .icon,
|
||||
.menu-item.has-open-submenu .check,
|
||||
.menu-item.has-open-submenu .submenu-arrow,
|
||||
.menu-item.has-open-submenu .shortcut,
|
||||
.menu-item.has-open-submenu .label {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .shortcut {
|
||||
color: white;
|
||||
}
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon svg,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon svg,
|
||||
.menu-item.has-open-submenu .icon svg {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon svg {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon img,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon img,
|
||||
.menu-item.has-open-submenu .icon img {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Safe-triangle: while the cursor traces a diagonal path toward
|
||||
an open submenu, suppress :hover highlight on intermediate
|
||||
items so they don't flash blue.
|
||||
Keyboard-nav: after a keyboard navigation, suppress :hover on
|
||||
the (now stale) item the mouse is still resting on so only
|
||||
the keyboard-focused item highlights. Cleared on next
|
||||
mousemove. .focused and .has-open-submenu (managed by JS)
|
||||
still highlight normally. */
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
|
||||
background-color: transparent;
|
||||
color: #333;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
|
||||
color: #333;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
|
||||
color: #999;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
|
||||
filter: none;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51));
|
||||
}
|
||||
|
||||
/* .has-open-context-menu-submenu — line 1738-1739 */
|
||||
.menu-item.has-open-submenu:not(:hover) {
|
||||
background-color: #dfdfdf;
|
||||
@@ -151,7 +185,6 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
padding-bottom: 5px;
|
||||
cursor: default;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
.divider hr {
|
||||
border: none;
|
||||
@@ -213,6 +246,8 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
margin-left: 16px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* === iOS-style action sheet (mobile) ========================= */
|
||||
@@ -261,6 +296,14 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts have no meaning on touch / small-screen
|
||||
devices — hide them so labels can use the full width. */
|
||||
@media (max-width: 480px), (pointer: coarse) {
|
||||
.shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host(.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) {
|
||||
background-color: rgba(0, 122, 255, 0.1);
|
||||
color: inherit;
|
||||
@@ -294,13 +337,109 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
|
||||
:host(.sheet-mode) .icon {
|
||||
width: 24px;
|
||||
margin-right: 12px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
:host(.sheet-mode) .icon svg,
|
||||
:host(.sheet-mode) .icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Dark theme — applied when system prefers dark and no light
|
||||
override is set, or when theme="dark" is forced. The base
|
||||
class toggles .puter-theme-dark on the host accordingly. */
|
||||
:host(.puter-theme-dark) .context-menu {
|
||||
background: #2d2d2d;
|
||||
background-color: rgb(45 45 45 / 94%);
|
||||
color: #e6e6e6;
|
||||
border-color: #00000080;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-item {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
/* Inactive items: icon/check/shortcut/arrow tones */
|
||||
:host(.puter-theme-dark) .icon,
|
||||
:host(.puter-theme-dark) .check {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .submenu-arrow {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
:host(.puter-theme-dark) .shortcut {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
|
||||
}
|
||||
/* Inactive icon SVGs use currentColor already; nothing to invert */
|
||||
|
||||
/* Safe-triangle / keyboard-nav: non-active hover restored colors should match dark */
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
|
||||
}
|
||||
|
||||
/* Submenu-open parent (no hover): subtle dark highlight */
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) {
|
||||
background-color: #3f3f3f;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon,
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon svg,
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon img {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
/* Danger items */
|
||||
:host(.puter-theme-dark) .menu-item.danger,
|
||||
:host(.puter-theme-dark) .menu-item.danger .icon {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
:host(.puter-theme-dark) .divider hr {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Sheet mode (mobile) */
|
||||
:host(.puter-theme-dark.sheet-mode) .context-menu {
|
||||
background-color: rgb(40 40 40 / 96%);
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) {
|
||||
background-color: rgba(0, 122, 255, 0.22);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) {
|
||||
background-color: rgba(0, 122, 255, 0.35);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .icon {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .divider hr {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -342,7 +481,7 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
: '';
|
||||
|
||||
const shortcutHTML = item.shortcut
|
||||
? `<span class="shortcut">${this._escapeHTML(item.shortcut)}</span>`
|
||||
? `<span class="shortcut">${this._escapeHTML(this._formatShortcut(item.shortcut))}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
@@ -431,7 +570,7 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
_bindEvents () {
|
||||
// Remove any stale document listeners from a prior render
|
||||
if ( this._outsideClickHandler ) {
|
||||
document.removeEventListener('click', this._outsideClickHandler, true);
|
||||
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}
|
||||
if ( this._keyHandler ) {
|
||||
document.removeEventListener('keydown', this._keyHandler, true);
|
||||
@@ -441,6 +580,33 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
this.#mouseTracker = null;
|
||||
}
|
||||
|
||||
// Hovering a separator or disabled item should drop the focus
|
||||
// highlight from the previously-focused item — the cursor has
|
||||
// clearly moved past it. It should also close any submenu opened
|
||||
// from an item above, so the parent's .has-open-submenu highlight
|
||||
// goes away too.
|
||||
// Exception: if the cursor is on a diagonal path toward the open
|
||||
// submenu, treat these like any other safe-traverse cell so we
|
||||
// don't tear down the submenu mid-drag.
|
||||
this.$$('.menu-item.divider, .menu-item.disabled').forEach((el) => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
if ( this.#activeSubmenu && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
|
||||
this._setSafeTraverse(true);
|
||||
if ( this.#submenuCloseTimer ) {
|
||||
clearTimeout(this.#submenuCloseTimer);
|
||||
this.#submenuCloseTimer = null;
|
||||
}
|
||||
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
|
||||
return;
|
||||
}
|
||||
this._setSafeTraverse(false);
|
||||
this._clearFocus();
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
this._cancelSubmenuClose();
|
||||
this._hideActiveSubmenu();
|
||||
});
|
||||
});
|
||||
|
||||
const menuItems = this.$$('.menu-item:not(.divider):not(.disabled)');
|
||||
|
||||
menuItems.forEach((el) => {
|
||||
@@ -471,7 +637,25 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
// Hover: mouse takes over focus highlight
|
||||
el.addEventListener('mouseenter', () => {
|
||||
if ( el.dataset.hasSubmenu === 'true' ) {
|
||||
// Safe-triangle: if a *different* submenu is already
|
||||
// open and the cursor is tracing diagonally toward it,
|
||||
// defer the swap. Without this, two adjacent items
|
||||
// with submenus would close the target as soon as the
|
||||
// cursor passed over the second one.
|
||||
if ( this.#activeSubmenu
|
||||
&& this.#activeSubmenu.parentEl !== el
|
||||
&& this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
|
||||
this.#pendingFocusIndex = index;
|
||||
this._setSafeTraverse(true);
|
||||
if ( this.#submenuCloseTimer ) {
|
||||
clearTimeout(this.#submenuCloseTimer);
|
||||
this.#submenuCloseTimer = null;
|
||||
}
|
||||
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
|
||||
return;
|
||||
}
|
||||
this.#pendingFocusIndex = null;
|
||||
this._setSafeTraverse(false);
|
||||
this._setFocusIndex(index);
|
||||
this._cancelSubmenuClose();
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
@@ -485,21 +669,34 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
}
|
||||
} else if ( this.#activeSubmenu ) {
|
||||
// Safe-triangle: if cursor is heading toward the submenu,
|
||||
// defer focus change so intermediate items don't highlight
|
||||
// defer focus change AND suppress :hover styling on this
|
||||
// item so it doesn't flash blue mid-traversal.
|
||||
if ( this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
|
||||
this.#pendingFocusIndex = index;
|
||||
this._cancelSubmenuClose();
|
||||
this._setSafeTraverse(true);
|
||||
// Don't call _cancelSubmenuClose — it clears
|
||||
// pendingFocusIndex. Just clear the close timer.
|
||||
if ( this.#submenuCloseTimer ) {
|
||||
clearTimeout(this.#submenuCloseTimer);
|
||||
this.#submenuCloseTimer = null;
|
||||
}
|
||||
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
|
||||
return;
|
||||
}
|
||||
this._setSafeTraverse(false);
|
||||
this._setFocusIndex(index);
|
||||
this._scheduleSubmenuClose();
|
||||
} else {
|
||||
this._setSafeTraverse(false);
|
||||
this._setFocusIndex(index);
|
||||
}
|
||||
});
|
||||
|
||||
el.addEventListener('mouseleave', () => {
|
||||
// iOS Safari synthesizes mouseleave on touchend after a tap,
|
||||
// which would schedule the just-opened submenu to close.
|
||||
// On touch, submenus are driven by explicit taps, not hover.
|
||||
if ( this._isMobile() ) return;
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
if ( el.dataset.hasSubmenu === 'true' && this.#activeSubmenu && this.#activeSubmenu.parentEl === el ) {
|
||||
this._scheduleSubmenuClose();
|
||||
@@ -507,20 +704,37 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
});
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
// When the cursor leaves the menu entirely, drop the item highlight
|
||||
// so the user isn't left with a stale focus mark while the cursor
|
||||
// is elsewhere. Submenu parents use .has-open-submenu (not .focused),
|
||||
// so this is safe even while a submenu is open.
|
||||
const menuRoot = this.$('.context-menu');
|
||||
if ( menuRoot ) {
|
||||
menuRoot.addEventListener('mouseleave', () => {
|
||||
this._clearFocus();
|
||||
});
|
||||
}
|
||||
|
||||
// Close on outside pointerdown — fires the instant the press starts,
|
||||
// before mouseup/click, so the menu doesn't linger during a drag.
|
||||
// Submenus are sibling elements appended to <body>, so we explicitly
|
||||
// walk the submenu chain — a click in a descendant submenu must not
|
||||
// tear us (and therefore that submenu) down.
|
||||
this._outsideClickHandler = (e) => {
|
||||
if ( ! this.contains(e.target) ) {
|
||||
if ( ! this._isEventInChain(e) ) {
|
||||
this._closeAll();
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this._outsideClickHandler, true);
|
||||
document.addEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}, 0);
|
||||
|
||||
// Track mouse for safe-triangle submenu hover
|
||||
// Track mouse for safe-triangle submenu hover. Also exits
|
||||
// keyboard-nav mode: once the cursor moves, :hover should win again.
|
||||
this.#mouseTracker = (e) => {
|
||||
this.#mouseLocs.push({ x: e.clientX, y: e.clientY });
|
||||
if ( this.#mouseLocs.length > 3 ) this.#mouseLocs.shift();
|
||||
this._setKeyboardNav(false);
|
||||
};
|
||||
document.addEventListener('mousemove', this.#mouseTracker);
|
||||
|
||||
@@ -529,6 +743,10 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( this.#activeSubmenu ) return; // let the deeper submenu handle
|
||||
const consumed = this._handleKey(e);
|
||||
if ( consumed ) {
|
||||
// A handled key (nav, typeahead, etc.) means the user is
|
||||
// driving by keyboard — suppress any stale :hover until the
|
||||
// mouse next moves.
|
||||
this._setKeyboardNav(true);
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
@@ -547,9 +765,24 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
case 'ArrowDown':
|
||||
this._moveFocus(+1);
|
||||
return true;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowUp': {
|
||||
// Root dropdown: ArrowUp on the first focusable item bubbles
|
||||
// to the menubar, which closes the dropdown and re-focuses
|
||||
// the parent menubar button.
|
||||
if ( ! this._parentMenu ) {
|
||||
const f = this._focusableIndices();
|
||||
if ( f.length && this.#focusedIndex === f[0] ) {
|
||||
this.dispatchEvent(new CustomEvent('puter-menu-navigate', {
|
||||
detail: { direction: 'up' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this._moveFocus(-1);
|
||||
return true;
|
||||
}
|
||||
case 'Home': {
|
||||
const f = this._focusableIndices();
|
||||
if ( f.length ) this._setFocusIndex(f[0]);
|
||||
@@ -660,14 +893,23 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( ! el ) return false;
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
this._cancelSubmenuClose();
|
||||
this._showSubmenu(el, item.items);
|
||||
// Focus first item in submenu
|
||||
// Don't re-open an already-open submenu for this parent — it would
|
||||
// wipe the user's focused item inside it. We only auto-focus the
|
||||
// first sub-item when the submenu is freshly opened by this call.
|
||||
const wasNewlyOpened = !this.#activeSubmenu || this.#activeSubmenu.parentEl !== el;
|
||||
if ( wasNewlyOpened ) {
|
||||
this._showSubmenu(el, item.items);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sub = this.#activeSubmenu && this.#activeSubmenu.element;
|
||||
if ( sub ) {
|
||||
if ( ! sub ) return;
|
||||
if ( wasNewlyOpened ) {
|
||||
const f = sub._focusableIndices();
|
||||
if ( f.length ) sub._setFocusIndex(f[0]);
|
||||
}
|
||||
if ( typeof sub._setKeyboardNav === 'function' ) {
|
||||
sub._setKeyboardNav(true);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -713,10 +955,18 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
this._cancelSubmenuClose();
|
||||
|
||||
parentEl.classList.add('has-open-submenu');
|
||||
// The parent now "owns" an open submenu; drop the .focused class so
|
||||
// it paints in the dim has-open-submenu state instead of the bright
|
||||
// focused/hover blue. :hover still keeps it blue when the cursor is
|
||||
// directly over it. #focusedIndex is preserved for ArrowLeft restore.
|
||||
parentEl.classList.remove('focused');
|
||||
|
||||
const submenu = document.createElement('puter-context-menu');
|
||||
submenu.setAttribute('data-submenu', '');
|
||||
submenu.setAttribute('data-parent-managed', '');
|
||||
// Forward any forced theme so the submenu paints in the same theme.
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
if ( themeAttr ) submenu.setAttribute('theme', themeAttr);
|
||||
submenu.items = items;
|
||||
submenu._parentMenu = this;
|
||||
submenu._parentItemEl = parentEl;
|
||||
@@ -809,13 +1059,17 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
clearTimeout(this.#submenuCloseTimer);
|
||||
this.#submenuCloseTimer = null;
|
||||
}
|
||||
// User reached the submenu — discard deferred focus
|
||||
// User reached the submenu — discard deferred focus and end traversal
|
||||
this.#pendingFocusIndex = null;
|
||||
this._setSafeTraverse(false);
|
||||
}
|
||||
|
||||
_submenuCloseCheck () {
|
||||
this.#submenuCloseTimer = null;
|
||||
if ( ! this.#activeSubmenu ) return;
|
||||
if ( ! this.#activeSubmenu ) {
|
||||
this._setSafeTraverse(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If cursor is currently over the submenu or the parent item, keep open
|
||||
const submenu = this.#activeSubmenu.element;
|
||||
@@ -823,6 +1077,8 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
const latest = this.#mouseLocs[this.#mouseLocs.length - 1];
|
||||
if ( latest ) {
|
||||
if ( this._pointInElement(latest, submenu) || this._pointInRect(latest, parentEl.getBoundingClientRect()) ) {
|
||||
// Cursor arrived at submenu / parent — end safe-triangle mode
|
||||
this._setSafeTraverse(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -834,9 +1090,21 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Trajectory no longer heading to submenu — end traversal mode
|
||||
this._setSafeTraverse(false);
|
||||
this._hideActiveSubmenu();
|
||||
}
|
||||
|
||||
_setSafeTraverse (on) {
|
||||
const menu = this.$('.context-menu');
|
||||
if ( menu ) menu.classList.toggle('safe-traverse', on);
|
||||
}
|
||||
|
||||
_setKeyboardNav (on) {
|
||||
const menu = this.$('.context-menu');
|
||||
if ( menu ) menu.classList.toggle('keyboard-nav', on);
|
||||
}
|
||||
|
||||
_pointInRect (p, r) {
|
||||
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||
}
|
||||
@@ -906,11 +1174,21 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
this.style.display = '';
|
||||
this._sheetHidden = false;
|
||||
}
|
||||
// Apply deferred focus from safe-triangle hover
|
||||
// Apply deferred focus from safe-triangle hover. If the deferred
|
||||
// item is itself a submenu opener, open it now — the user crossed
|
||||
// the safe triangle without reaching the previous submenu, so we
|
||||
// commit to the new target.
|
||||
if ( this.#pendingFocusIndex !== null ) {
|
||||
this._setFocusIndex(this.#pendingFocusIndex);
|
||||
const idx = this.#pendingFocusIndex;
|
||||
this.#pendingFocusIndex = null;
|
||||
this._setFocusIndex(idx);
|
||||
const pending = this.#items[idx];
|
||||
if ( pending && pending.items && pending.items.length ) {
|
||||
const pendingEl = this._itemEl(idx);
|
||||
if ( pendingEl ) this._showSubmenu(pendingEl, pending.items);
|
||||
}
|
||||
}
|
||||
this._setSafeTraverse(false);
|
||||
}
|
||||
|
||||
_closeAll () {
|
||||
@@ -924,7 +1202,7 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
const wasHidden = this._sheetHidden;
|
||||
this._hideActiveSubmenu(false);
|
||||
if ( this._outsideClickHandler ) {
|
||||
document.removeEventListener('click', this._outsideClickHandler, true);
|
||||
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}
|
||||
if ( this._keyHandler ) {
|
||||
document.removeEventListener('keydown', this._keyHandler, true);
|
||||
@@ -952,8 +1230,9 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
if ( this._outsideClickHandler ) {
|
||||
document.removeEventListener('click', this._outsideClickHandler, true);
|
||||
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}
|
||||
if ( this._keyHandler ) {
|
||||
document.removeEventListener('keydown', this._keyHandler, true);
|
||||
@@ -980,6 +1259,89 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( ! str ) return '';
|
||||
return str.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a keyboard-shortcut string in OS-appropriate form.
|
||||
*
|
||||
* Mac: modifiers as glyphs, concatenated → ⇧⌘D
|
||||
* Win/Linux: text labels joined with '+' → Ctrl+Shift+D
|
||||
*
|
||||
* Accepts portable tokens (Mod, Cmd, Ctrl, Alt, Option, Shift, Meta) and
|
||||
* the literal Mac glyphs (⌘ ⌃ ⌥ ⇧). 'Mod' is the recommended portable
|
||||
* name — it maps to Cmd on Mac and Ctrl elsewhere.
|
||||
*/
|
||||
_formatShortcut (str) {
|
||||
if ( ! str ) return '';
|
||||
const isMac = PuterContextMenu._isMac();
|
||||
|
||||
// Inflate any glyphs into named tokens so we can re-emit per OS.
|
||||
const normalized = String(str)
|
||||
.replace(/⌘/g, 'Mod+') // ⌘
|
||||
.replace(/⌃/g, 'Ctrl+') // ⌃
|
||||
.replace(/⌥/g, 'Alt+') // ⌥
|
||||
.replace(/⇧/g, 'Shift+'); // ⇧
|
||||
|
||||
const tokens = normalized.split('+').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
const out = tokens.map(t => {
|
||||
switch ( t.toLowerCase() ) {
|
||||
case 'mod':
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
return isMac ? '⌘' : 'Ctrl';
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
return isMac ? '⌃' : 'Ctrl';
|
||||
case 'alt':
|
||||
case 'option':
|
||||
case 'opt':
|
||||
return isMac ? '⌥' : 'Alt';
|
||||
case 'shift':
|
||||
return isMac ? '⇧' : 'Shift';
|
||||
case 'meta':
|
||||
case 'super':
|
||||
case 'win':
|
||||
return isMac ? '⌘' : 'Win';
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
return isMac ? out.join('') : out.join('+');
|
||||
}
|
||||
|
||||
_getActiveSubmenu () {
|
||||
return this.#activeSubmenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a document-level event targets this menu or any
|
||||
* submenu nested below it. e.target on shadow-DOM-crossing events is
|
||||
* the submenu's host element, so we use contains() on each host in the
|
||||
* chain.
|
||||
*/
|
||||
_isEventInChain (e) {
|
||||
const target = e.target;
|
||||
if ( ! target ) return false;
|
||||
if ( this.contains(target) ) return true;
|
||||
let cur = this.#activeSubmenu;
|
||||
while ( cur && cur.element ) {
|
||||
if ( cur.element.contains(target) ) return true;
|
||||
cur = cur.element._getActiveSubmenu
|
||||
? cur.element._getActiveSubmenu()
|
||||
: null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static _isMac () {
|
||||
if ( typeof navigator === 'undefined' ) return false;
|
||||
const uaData = navigator.userAgentData;
|
||||
if ( uaData && typeof uaData.platform === 'string' ) {
|
||||
return /mac/i.test(uaData.platform);
|
||||
}
|
||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');
|
||||
}
|
||||
}
|
||||
|
||||
export default PuterContextMenu;
|
||||
@@ -185,6 +185,49 @@ class PuterFontPicker extends PuterWebComponent {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .picker-body {
|
||||
background-color: rgba(40, 44, 52, .95);
|
||||
color: #e6e6e6;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .title {
|
||||
color: #e6e6e6;
|
||||
text-shadow: none;
|
||||
}
|
||||
:host(.puter-theme-dark) .search {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-list {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-item {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:host(.puter-theme-dark) .font-name-label {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .preview {
|
||||
background: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .btn {
|
||||
color: #e6e6e6;
|
||||
border-color: #555;
|
||||
background: linear-gradient(#4a4a4a, #3a3a3a);
|
||||
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn:active {
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,25 @@ class PuterMenubar extends PuterWebComponent {
|
||||
line-height: 1.2;
|
||||
margin: 0 1px;
|
||||
}
|
||||
.menu-button:hover,
|
||||
/* Hover is driven by JS (.hovered) rather than :hover so we can
|
||||
clear stale keyboard focus the moment the mouse takes over and
|
||||
keep the two highlight sources from ever co-existing. */
|
||||
.menu-button.hovered,
|
||||
.menu-button.active,
|
||||
.menu-button.focused {
|
||||
background-color: #e2e2e2;
|
||||
}
|
||||
.menu-button:focus { outline: none; }
|
||||
/* Suppress browser-native focus ring and tap highlight without
|
||||
touching background. CAUTION: setting background-color here
|
||||
would tie specificity with .menu-button.active and win by
|
||||
source order, which would erase the open-menu highlight as
|
||||
soon as the button takes DOM :focus from a click. */
|
||||
.menu-button:focus,
|
||||
.menu-button:focus-visible,
|
||||
.menu-button:active {
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.menubar {
|
||||
height: 40px;
|
||||
@@ -86,6 +99,21 @@ class PuterMenubar extends PuterWebComponent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
/* Dark theme — applied when system prefers dark and no light
|
||||
override is set, or when theme="dark" is forced. The base
|
||||
class toggles .puter-theme-dark on the host accordingly. */
|
||||
:host(.puter-theme-dark) .menubar {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-button {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-button.hovered,
|
||||
:host(.puter-theme-dark) .menu-button.active,
|
||||
:host(.puter-theme-dark) .menu-button.focused {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -105,6 +133,18 @@ class PuterMenubar extends PuterWebComponent {
|
||||
if ( this._keyUpHandler ) {
|
||||
document.removeEventListener('keyup', this._keyUpHandler, true);
|
||||
}
|
||||
if ( this._docPointerDownHandler ) {
|
||||
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
}
|
||||
if ( this._docFocusInHandler ) {
|
||||
document.removeEventListener('focusin', this._docFocusInHandler, true);
|
||||
}
|
||||
if ( this._winBlurHandler ) {
|
||||
window.removeEventListener('blur', this._winBlurHandler);
|
||||
}
|
||||
if ( this._mouseMoveHandler ) {
|
||||
document.removeEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
|
||||
const buttons = this.$$('.menu-button');
|
||||
buttons.forEach((btn) => {
|
||||
@@ -114,6 +154,14 @@ class PuterMenubar extends PuterWebComponent {
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// The dropdown closes on outside pointerdown, which fires
|
||||
// before this click. If the user is pressing the same button
|
||||
// that just closed, treat the press as a toggle-close —
|
||||
// don't reopen on the trailing click.
|
||||
if ( this._suppressClickFor === btn ) {
|
||||
this._suppressClickFor = null;
|
||||
return;
|
||||
}
|
||||
this.#focusedIndex = index;
|
||||
this.#menubarActive = true;
|
||||
if ( this.#activeButtonEl === btn ) {
|
||||
@@ -124,19 +172,91 @@ class PuterMenubar extends PuterWebComponent {
|
||||
this._openDropdown(btn, item);
|
||||
});
|
||||
|
||||
// Hover-switch when a dropdown is already open
|
||||
// JS-managed hover. Adding .hovered while clearing any keyboard
|
||||
// .focused guarantees only one button highlights at a time, no
|
||||
// matter the source.
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.classList.add('hovered');
|
||||
// Mouse takes over: clear keyboard focus from everywhere
|
||||
// and drop any lingering DOM :focus on a sibling button.
|
||||
if ( this.#focusedIndex !== null ) {
|
||||
this.#focusedIndex = null;
|
||||
this._renderButtonFocus();
|
||||
}
|
||||
const root = this.shadowRoot;
|
||||
const active = root && root.activeElement;
|
||||
if ( active && active !== btn && active.classList.contains('menu-button') ) {
|
||||
active.blur();
|
||||
}
|
||||
this._setKeyboardNav(false);
|
||||
// Hover-switch when a dropdown is already open
|
||||
if ( this.#activeDropdown && this.#activeButtonEl !== btn ) {
|
||||
this.#focusedIndex = index;
|
||||
this._openDropdown(btn, item);
|
||||
}
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.classList.remove('hovered');
|
||||
});
|
||||
});
|
||||
|
||||
this._keyHandler = (e) => this._onGlobalKeyDown(e);
|
||||
this._keyUpHandler = (e) => this._onGlobalKeyUp(e);
|
||||
document.addEventListener('keydown', this._keyHandler, true);
|
||||
document.addEventListener('keyup', this._keyUpHandler, true);
|
||||
|
||||
// Capture-phase pointerdown on document. The open context menu also
|
||||
// listens in capture for outside-pointerdown to close itself; that
|
||||
// listener registers later (when the dropdown opens) so ours runs
|
||||
// first. If the press lands on the currently-active button, mark it
|
||||
// so the trailing click — which would otherwise reopen the just-closed
|
||||
// dropdown — is treated as a toggle-close. Uses composedPath because
|
||||
// the button lives inside this shadow root.
|
||||
this._docPointerDownHandler = (e) => {
|
||||
const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
|
||||
if ( this.#activeButtonEl && path.includes(this.#activeButtonEl) ) {
|
||||
this._suppressClickFor = this.#activeButtonEl;
|
||||
clearTimeout(this._suppressClickTimer);
|
||||
this._suppressClickTimer = setTimeout(() => {
|
||||
this._suppressClickFor = null;
|
||||
}, 400);
|
||||
}
|
||||
// If the click lands fully outside the menubar host, drop any
|
||||
// residual keyboard state. Without this, an Alt-activated menubar
|
||||
// would keep intercepting arrow keys typed into other inputs.
|
||||
// When a dropdown is open, its own close event will deactivate
|
||||
// the menubar — leave that path alone here.
|
||||
if ( this.#menubarActive && ! this.#activeDropdown && ! path.includes(this) ) {
|
||||
this._deactivateMenubar();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
|
||||
// Focus moving to an element outside the menubar (e.g. a text input)
|
||||
// should also drop menubar state so global key handling stops.
|
||||
this._docFocusInHandler = (e) => {
|
||||
if ( ! this.#menubarActive ) return;
|
||||
if ( this.#activeDropdown ) return;
|
||||
const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
|
||||
if ( ! path.includes(this) ) {
|
||||
this._deactivateMenubar();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', this._docFocusInHandler, true);
|
||||
|
||||
// Window losing focus (alt-tab, devtools, etc.) — reset state.
|
||||
this._winBlurHandler = () => {
|
||||
if ( this.#menubarActive && ! this.#activeDropdown ) {
|
||||
this._deactivateMenubar();
|
||||
}
|
||||
this.#altDown = false;
|
||||
this.#altConsumed = false;
|
||||
};
|
||||
window.addEventListener('blur', this._winBlurHandler);
|
||||
|
||||
// Once the user actually moves the mouse, exit keyboard-nav mode so
|
||||
// :hover styling on menubar buttons works normally again.
|
||||
this._mouseMoveHandler = () => this._setKeyboardNav(false);
|
||||
document.addEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
|
||||
_onGlobalKeyDown (e) {
|
||||
@@ -167,10 +287,10 @@ class PuterMenubar extends PuterWebComponent {
|
||||
|
||||
switch ( e.key ) {
|
||||
case 'ArrowRight':
|
||||
this._moveButtonFocus(+1);
|
||||
this._moveButtonFocus(+1, { openDropdown: true });
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
this._moveButtonFocus(-1);
|
||||
this._moveButtonFocus(-1, { openDropdown: true });
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case 'Enter':
|
||||
@@ -213,12 +333,14 @@ class PuterMenubar extends PuterWebComponent {
|
||||
this.#menubarActive = true;
|
||||
this.#focusedIndex = 0;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
}
|
||||
|
||||
_deactivateMenubar () {
|
||||
this.#menubarActive = false;
|
||||
this.#focusedIndex = null;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(false);
|
||||
}
|
||||
|
||||
_renderButtonFocus () {
|
||||
@@ -228,19 +350,35 @@ class PuterMenubar extends PuterWebComponent {
|
||||
});
|
||||
}
|
||||
|
||||
_moveButtonFocus (delta, { swapDropdown = true } = {}) {
|
||||
_setKeyboardNav (on) {
|
||||
const menubar = this.$('.menubar');
|
||||
if ( menubar ) menubar.classList.toggle('keyboard-nav', on);
|
||||
}
|
||||
|
||||
_moveButtonFocus (delta, { swapDropdown = true, openDropdown = false } = {}) {
|
||||
if ( ! this.#items.length ) return;
|
||||
const n = this.#items.length;
|
||||
const cur = this.#focusedIndex == null ? (delta > 0 ? -1 : 0) : this.#focusedIndex;
|
||||
const next = (cur + delta + n) % n;
|
||||
this.#focusedIndex = next;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
|
||||
const btn = this._buttonEl(next);
|
||||
const item = this.#items[next];
|
||||
if ( ! btn || ! item ) return;
|
||||
|
||||
// If a dropdown is already open, swap to the new button's dropdown
|
||||
if ( swapDropdown && this.#activeDropdown ) {
|
||||
const btn = this._buttonEl(next);
|
||||
const item = this.#items[next];
|
||||
if ( btn && item ) this._openDropdown(btn, item);
|
||||
this._openDropdown(btn, item);
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow-nav at the menubar level opens the focused button's dropdown
|
||||
// but does NOT pre-focus the first item — let the user press ArrowDown
|
||||
// to enter the menu.
|
||||
if ( openDropdown ) {
|
||||
this._openFocusedButton(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +398,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
if ( dd && typeof dd._focusableIndices === 'function' ) {
|
||||
const f = dd._focusableIndices();
|
||||
if ( f.length ) dd._setFocusIndex(f[0]);
|
||||
if ( typeof dd._setKeyboardNav === 'function' ) {
|
||||
dd._setKeyboardNav(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -280,6 +421,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
const rect = buttonEl.getBoundingClientRect();
|
||||
const dropdown = document.createElement('puter-context-menu');
|
||||
dropdown.setAttribute('data-submenu', ''); // skip mobile sheet behavior
|
||||
// Forward any forced theme so the dropdown paints in the same theme.
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
if ( themeAttr ) dropdown.setAttribute('theme', themeAttr);
|
||||
dropdown.items = item.items;
|
||||
dropdown.setAttribute('x', String(rect.left));
|
||||
dropdown.setAttribute('y', String(rect.bottom));
|
||||
@@ -290,19 +434,32 @@ class PuterMenubar extends PuterWebComponent {
|
||||
this._deactivateMenubar();
|
||||
});
|
||||
dropdown.addEventListener('close', () => {
|
||||
// The context menu closes itself on outside click; sync our state
|
||||
// The context menu closes itself on outside click / Escape /
|
||||
// selection. Sync our state and fully deactivate the menubar so
|
||||
// a stray arrow / Enter / Space keypress doesn't re-open it.
|
||||
if ( this.#activeDropdown === dropdown ) {
|
||||
buttonEl.classList.remove('active');
|
||||
this.#activeDropdown = null;
|
||||
this.#activeButtonEl = null;
|
||||
this._deactivateMenubar();
|
||||
}
|
||||
});
|
||||
// Keyboard navigate request bubbling from the context menu
|
||||
// Keyboard navigate request bubbling from the context menu.
|
||||
// Arrow-left/right closes the current dropdown and opens the adjacent
|
||||
// button's dropdown, mirroring menubar-level arrow nav.
|
||||
// Arrow-up at the dropdown's first item closes it and returns
|
||||
// focus to the same menubar button (which can re-open with ArrowDown).
|
||||
dropdown.addEventListener('puter-menu-navigate', (e) => {
|
||||
if ( ! e.detail ) return;
|
||||
if ( e.detail.direction === 'up' ) {
|
||||
this._closeDropdown();
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
return;
|
||||
}
|
||||
const delta = e.detail.direction === 'right' ? +1 : -1;
|
||||
this._moveButtonFocus(delta, { swapDropdown: false });
|
||||
this._openFocusedButton(true);
|
||||
this._closeDropdown();
|
||||
this._moveButtonFocus(delta, { swapDropdown: false, openDropdown: true });
|
||||
});
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -324,13 +481,28 @@ class PuterMenubar extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
this._closeDropdown();
|
||||
clearTimeout(this._suppressClickTimer);
|
||||
this._suppressClickFor = null;
|
||||
if ( this._keyHandler ) {
|
||||
document.removeEventListener('keydown', this._keyHandler, true);
|
||||
}
|
||||
if ( this._keyUpHandler ) {
|
||||
document.removeEventListener('keyup', this._keyUpHandler, true);
|
||||
}
|
||||
if ( this._docPointerDownHandler ) {
|
||||
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
}
|
||||
if ( this._docFocusInHandler ) {
|
||||
document.removeEventListener('focusin', this._docFocusInHandler, true);
|
||||
}
|
||||
if ( this._winBlurHandler ) {
|
||||
window.removeEventListener('blur', this._winBlurHandler);
|
||||
}
|
||||
if ( this._mouseMoveHandler ) {
|
||||
document.removeEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_escapeHTML (str) {
|
||||
|
||||
@@ -175,6 +175,29 @@ class PuterNotification extends PuterWebComponent {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .notification {
|
||||
background: #2d2d2dcd;
|
||||
border-color: #3a3a3a;
|
||||
box-shadow: 0px 0px 17px -6px #000;
|
||||
}
|
||||
:host(.puter-theme-dark) .close-btn {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
|
||||
}
|
||||
:host(.puter-theme-dark) .close-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
:host(.puter-theme-dark) .icon-area {
|
||||
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
|
||||
}
|
||||
:host(.puter-theme-dark) .title {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .text {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -255,6 +278,7 @@ class PuterNotification extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
if ( this._dismissTimer ) clearTimeout(this._dismissTimer);
|
||||
const idx = activeNotifications.indexOf(this);
|
||||
if ( idx !== -1 ) activeNotifications.splice(idx, 1);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<script src="./fs.test.js"></script>
|
||||
<script src="./ai.test.js"></script>
|
||||
<script src="./txt2speech.test.js"></script>
|
||||
<script src="./txt2img.test.js"></script>
|
||||
<script src="./txt2vid.test.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -701,6 +703,36 @@
|
||||
</div>`);
|
||||
}
|
||||
|
||||
$('#tests').append('<h2><label><input type="checkbox" id="txt2imgTests-group"> Text-to-Image</label></h2>');
|
||||
for (let i = 0; i < txt2imgTests.length; i++) {
|
||||
const testInfo = getTestInfo(txt2imgTests[i]);
|
||||
$('#tests').append(`<div class="test-container" id="txt2imgTests-container-${i}">
|
||||
<div class="test-checkbox-container">
|
||||
<input type="checkbox" class="test-checkbox txt2imgTests-checkbox" id="txt2imgTests${i}">
|
||||
<label for="txt2imgTests${i}">
|
||||
<div class="test-name">${testInfo.name}</div>
|
||||
<div class="test-description">${testInfo.description}</div>
|
||||
</label><br>
|
||||
<button class="test-run-button" onclick="runSingleTest('txt2img', ${i})">Run Test</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
$('#tests').append('<h2><label><input type="checkbox" id="txt2vidTests-group"> Text-to-Video</label></h2>');
|
||||
for (let i = 0; i < txt2vidTests.length; i++) {
|
||||
const testInfo = getTestInfo(txt2vidTests[i]);
|
||||
$('#tests').append(`<div class="test-container" id="txt2vidTests-container-${i}">
|
||||
<div class="test-checkbox-container">
|
||||
<input type="checkbox" class="test-checkbox txt2vidTests-checkbox" id="txt2vidTests${i}">
|
||||
<label for="txt2vidTests${i}">
|
||||
<div class="test-name">${testInfo.name}</div>
|
||||
<div class="test-description">${testInfo.description}</div>
|
||||
</label><br>
|
||||
<button class="test-run-button" onclick="runSingleTest('txt2vid', ${i})">Run Test</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// Add event listeners for group checkboxes
|
||||
$('#fsTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
@@ -722,6 +754,16 @@
|
||||
$('.txt2speechTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
$('#txt2imgTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.txt2imgTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
$('#txt2vidTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.txt2vidTests-checkbox').prop('checked', isChecked);
|
||||
});
|
||||
|
||||
// Add event listeners for individual checkboxes to update group checkbox state
|
||||
$(document).on('change', '.fsTests-checkbox', function() {
|
||||
const totalFsTests = $('.fsTests-checkbox').length;
|
||||
@@ -765,7 +807,7 @@
|
||||
$(document).on('change', '.txt2speechTests-checkbox', function() {
|
||||
const totalTxt2speechTests = $('.txt2speechTests-checkbox').length;
|
||||
const checkedTxt2speechTests = $('.txt2speechTests-checkbox:checked').length;
|
||||
|
||||
|
||||
if (checkedTxt2speechTests === 0) {
|
||||
$('#txt2speechTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2speechTests === totalTxt2speechTests) {
|
||||
@@ -775,6 +817,32 @@
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('change', '.txt2imgTests-checkbox', function() {
|
||||
const totalTxt2imgTests = $('.txt2imgTests-checkbox').length;
|
||||
const checkedTxt2imgTests = $('.txt2imgTests-checkbox:checked').length;
|
||||
|
||||
if (checkedTxt2imgTests === 0) {
|
||||
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2imgTests === totalTxt2imgTests) {
|
||||
$('#txt2imgTests-group').prop('checked', true).prop('indeterminate', false);
|
||||
} else {
|
||||
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', true);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('change', '.txt2vidTests-checkbox', function() {
|
||||
const totalTxt2vidTests = $('.txt2vidTests-checkbox').length;
|
||||
const checkedTxt2vidTests = $('.txt2vidTests-checkbox:checked').length;
|
||||
|
||||
if (checkedTxt2vidTests === 0) {
|
||||
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checkedTxt2vidTests === totalTxt2vidTests) {
|
||||
$('#txt2vidTests-group').prop('checked', true).prop('indeterminate', false);
|
||||
} else {
|
||||
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', true);
|
||||
}
|
||||
});
|
||||
|
||||
window.assert = function(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message || "Assertion failed");
|
||||
@@ -786,7 +854,9 @@
|
||||
'fs': fsTests,
|
||||
'kv': kvTests,
|
||||
'ai': aiTests,
|
||||
'txt2speech': txt2speechTests
|
||||
'txt2speech': txt2speechTests,
|
||||
'txt2img': txt2imgTests,
|
||||
'txt2vid': txt2vidTests
|
||||
};
|
||||
|
||||
const tests = testSuites[testType];
|
||||
@@ -956,15 +1026,71 @@
|
||||
$(`#txt2speechTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
|
||||
// Small delay to make progress visible
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i < txt2imgTests.length; i++) {
|
||||
if (document.getElementById(`txt2imgTests${i}`).checked) {
|
||||
const testInfo = getTestInfo(txt2imgTests[i]);
|
||||
testProgress.currentTest = `Text-to-Image: ${testInfo.name}`;
|
||||
updateProgressPanel();
|
||||
|
||||
try{
|
||||
await executeTest(txt2imgTests[i]);
|
||||
$(`#txt2imgTests-container-${i}`).css('background-color', '#85e085');
|
||||
testProgress.passed++;
|
||||
} catch (e) {
|
||||
console.error('Txt2Img Test failed:', testInfo.name, e);
|
||||
$(`#txt2imgTests-container-${i}`).css('background-color', '#ff8484');
|
||||
let errorMessage = e.message || e.toString();
|
||||
if (e.originalError) {
|
||||
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
|
||||
}
|
||||
$(`#txt2imgTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < txt2vidTests.length; i++) {
|
||||
if (document.getElementById(`txt2vidTests${i}`).checked) {
|
||||
const testInfo = getTestInfo(txt2vidTests[i]);
|
||||
testProgress.currentTest = `Text-to-Video: ${testInfo.name}`;
|
||||
updateProgressPanel();
|
||||
|
||||
try{
|
||||
await executeTest(txt2vidTests[i]);
|
||||
$(`#txt2vidTests-container-${i}`).css('background-color', '#85e085');
|
||||
testProgress.passed++;
|
||||
} catch (e) {
|
||||
console.error('Txt2Vid Test failed:', testInfo.name, e);
|
||||
$(`#txt2vidTests-container-${i}`).css('background-color', '#ff8484');
|
||||
let errorMessage = e.message || e.toString();
|
||||
if (e.originalError) {
|
||||
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
|
||||
}
|
||||
$(`#txt2vidTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Show completion message
|
||||
testProgress.currentTest = `Complete! ${testProgress.passed} passed, ${testProgress.failed} failed`;
|
||||
updateProgressPanel();
|
||||
@@ -996,7 +1122,7 @@
|
||||
$('#master-checkbox').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
$('.test-checkbox').prop('checked', isChecked);
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').prop('checked', isChecked);
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').prop('checked', isChecked);
|
||||
// Update the counter display
|
||||
updateMasterCheckboxState();
|
||||
});
|
||||
@@ -1024,7 +1150,7 @@
|
||||
});
|
||||
|
||||
// Update master checkbox state when group checkboxes change
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').change(function() {
|
||||
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').change(function() {
|
||||
updateMasterCheckboxState();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
|
||||
// Core test functions for txt2img functionality
|
||||
const testTxt2ImgBasicCore = async function() {
|
||||
const result = await puter.ai.txt2img("A red circle on a white background", true);
|
||||
|
||||
assert(result instanceof Image, "txt2img should return an Image object");
|
||||
assert(result !== null, "txt2img should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
const isValidUrl = result.src.startsWith('blob:') ||
|
||||
result.src.startsWith('data:') ||
|
||||
result.src.startsWith('http:') ||
|
||||
result.src.startsWith('https:');
|
||||
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
|
||||
|
||||
assert(typeof result.toString === 'function', "result should have toString method");
|
||||
assert(typeof result.valueOf === 'function', "result should have valueOf method");
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2ImgWithOptionsCore = async function() {
|
||||
const result = await puter.ai.txt2img("A blue square", {
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with options should return an Image object");
|
||||
assert(result !== null, "txt2img with options should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2ImgObjectSyntaxCore = async function() {
|
||||
const result = await puter.ai.txt2img({
|
||||
prompt: "A green triangle",
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img object syntax should return an Image object");
|
||||
assert(result !== null, "txt2img object syntax should not return null");
|
||||
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathCore = async function() {
|
||||
const outputPath = `test_output_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("A yellow star on black background", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with puter_output_path should return an Image object");
|
||||
assert(result !== null, "txt2img with puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written to the filesystem
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at the output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathAbsoluteCore = async function() {
|
||||
const user = await puter.auth.getUser();
|
||||
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("A purple diamond", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with absolute puter_output_path should return an Image");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathHomeTildeCore = async function() {
|
||||
const outputPath = `~/test_output_tilde_${Date.now()}.png`;
|
||||
|
||||
const result = await puter.ai.txt2img("An orange hexagon", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result instanceof Image, "txt2img with ~ puter_output_path should return an Image");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written (resolve ~ for stat)
|
||||
const user = await puter.auth.getUser();
|
||||
const resolvedPath = outputPath.replace('~', `/${user.username}`);
|
||||
const stat = await puter.fs.stat(resolvedPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(resolvedPath);
|
||||
};
|
||||
|
||||
const testTxt2ImgPuterOutputPathPermissionDeniedCore = async function() {
|
||||
let caught = false;
|
||||
let caughtError = null;
|
||||
try {
|
||||
await puter.ai.txt2img("A test image", {
|
||||
test_mode: true,
|
||||
puter_output_path: "/some_other_user/no_access/image.png",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = true;
|
||||
caughtError = error;
|
||||
}
|
||||
|
||||
assert(caught, "txt2img should throw when writing to a path without permission");
|
||||
assert(caughtError !== null, "error should not be null");
|
||||
|
||||
// The error should contain the actual backend error, NOT the generic
|
||||
// "Unexpected image response format" message
|
||||
const errMsg = typeof caughtError === 'string' ? caughtError
|
||||
: caughtError?.error?.message ?? caughtError?.message ?? '';
|
||||
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
|
||||
|
||||
assert(
|
||||
!errMsg.includes('Unexpected image response format'),
|
||||
`Should surface the real backend error, not the generic 'Unexpected image response format'. Got: ${errMsg}`
|
||||
);
|
||||
assert(
|
||||
errCode !== 'invalid_image_response',
|
||||
`Error code should not be 'invalid_image_response'. Got code: ${errCode}, message: ${errMsg}`
|
||||
);
|
||||
};
|
||||
|
||||
// Export test functions
|
||||
window.txt2imgTests = [
|
||||
{
|
||||
name: "testTxt2ImgBasic",
|
||||
description: "Test basic text-to-image generation with test mode and verify Image object structure",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgBasicCore();
|
||||
pass("testTxt2ImgBasic passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgBasic failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgWithOptions",
|
||||
description: "Test txt2img with prompt string and options object",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgWithOptionsCore();
|
||||
pass("testTxt2ImgWithOptions passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgWithOptions failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgObjectSyntax",
|
||||
description: "Test txt2img with single options object containing prompt",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgObjectSyntaxCore();
|
||||
pass("testTxt2ImgObjectSyntax passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgObjectSyntax failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPath",
|
||||
description: "Test that puter_output_path writes the generated image to the Puter filesystem (relative path)",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathCore();
|
||||
pass("testTxt2ImgPuterOutputPath passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPath failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathAbsolute",
|
||||
description: "Test puter_output_path with an absolute path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathAbsoluteCore();
|
||||
pass("testTxt2ImgPuterOutputPathAbsolute passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathAbsolute failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathHomeTilde",
|
||||
description: "Test puter_output_path with a ~/... home-relative path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathHomeTildeCore();
|
||||
pass("testTxt2ImgPuterOutputPathHomeTilde passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathHomeTilde failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2ImgPuterOutputPathPermissionDenied",
|
||||
description: "Test that writing to a path without permission surfaces the real backend error, not a generic one",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2ImgPuterOutputPathPermissionDeniedCore();
|
||||
pass("testTxt2ImgPuterOutputPathPermissionDenied passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2ImgPuterOutputPathPermissionDenied failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,239 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
|
||||
// Core test functions for txt2vid functionality
|
||||
const testTxt2VidBasicCore = async function() {
|
||||
const result = await puter.ai.txt2vid("A sunrise over the ocean", true);
|
||||
|
||||
assert(result !== null, "txt2vid should not return null");
|
||||
assert(typeof result === 'object', "txt2vid should return an object");
|
||||
|
||||
// Should be a video element (or object with video-like properties in non-DOM envs)
|
||||
assert(typeof result.src === 'string', "result should have src property as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
const isValidUrl = result.src.startsWith('blob:') ||
|
||||
result.src.startsWith('data:') ||
|
||||
result.src.startsWith('http:') ||
|
||||
result.src.startsWith('https:');
|
||||
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
|
||||
|
||||
assert(typeof result.toString === 'function', "result should have toString method");
|
||||
assert(typeof result.valueOf === 'function', "result should have valueOf method");
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2VidWithOptionsCore = async function() {
|
||||
const result = await puter.ai.txt2vid("A cat walking through grass", {
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with options should not return null");
|
||||
assert(typeof result === 'object', "txt2vid with options should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
assert(result.toString() === result.src, "toString() should return src");
|
||||
assert(result.valueOf() === result.src, "valueOf() should return src");
|
||||
};
|
||||
|
||||
const testTxt2VidObjectSyntaxCore = async function() {
|
||||
const result = await puter.ai.txt2vid({
|
||||
prompt: "Clouds drifting across a blue sky",
|
||||
test_mode: true,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid object syntax should not return null");
|
||||
assert(typeof result === 'object', "txt2vid object syntax should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathCore = async function() {
|
||||
const outputPath = `test_output_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("A ball rolling down a hill", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with puter_output_path should not return null");
|
||||
assert(typeof result === 'object', "txt2vid with puter_output_path should return an object");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written to the filesystem
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at the output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathAbsoluteCore = async function() {
|
||||
const user = await puter.auth.getUser();
|
||||
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("Rain falling on a window", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with absolute puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written
|
||||
const stat = await puter.fs.stat(outputPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(outputPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathHomeTildeCore = async function() {
|
||||
const outputPath = `~/test_output_tilde_${Date.now()}.mp4`;
|
||||
|
||||
const result = await puter.ai.txt2vid("Waves crashing on a beach", {
|
||||
test_mode: true,
|
||||
puter_output_path: outputPath,
|
||||
});
|
||||
|
||||
assert(result !== null, "txt2vid with ~ puter_output_path should not return null");
|
||||
assert(typeof result.src === 'string', "result should have src as string");
|
||||
assert(result.src.length > 0, "src should not be empty");
|
||||
|
||||
// Verify the file was written (resolve ~ for stat)
|
||||
const user = await puter.auth.getUser();
|
||||
const resolvedPath = outputPath.replace('~', `/${user.username}`);
|
||||
const stat = await puter.fs.stat(resolvedPath);
|
||||
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
|
||||
assert(stat.size > 0, "written file should not be empty");
|
||||
|
||||
// Clean up
|
||||
await puter.fs.delete(resolvedPath);
|
||||
};
|
||||
|
||||
const testTxt2VidPuterOutputPathPermissionDeniedCore = async function() {
|
||||
let caught = false;
|
||||
let caughtError = null;
|
||||
try {
|
||||
await puter.ai.txt2vid("A test video", {
|
||||
test_mode: true,
|
||||
puter_output_path: "/some_other_user/no_access/video.mp4",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = true;
|
||||
caughtError = error;
|
||||
}
|
||||
|
||||
assert(caught, "txt2vid should throw when writing to a path without permission");
|
||||
assert(caughtError !== null, "error should not be null");
|
||||
|
||||
// The error should contain the actual backend error, NOT a generic message
|
||||
const errMsg = typeof caughtError === 'string' ? caughtError
|
||||
: caughtError?.error?.message ?? caughtError?.message ?? '';
|
||||
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
|
||||
|
||||
assert(
|
||||
errCode !== 'invalid_video_response',
|
||||
`Error code should not be generic. Got code: ${errCode}, message: ${errMsg}`
|
||||
);
|
||||
};
|
||||
|
||||
// Export test functions
|
||||
window.txt2vidTests = [
|
||||
{
|
||||
name: "testTxt2VidBasic",
|
||||
description: "Test basic text-to-video generation with test mode and verify video element structure",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidBasicCore();
|
||||
pass("testTxt2VidBasic passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidBasic failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidWithOptions",
|
||||
description: "Test txt2vid with prompt string and options object",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidWithOptionsCore();
|
||||
pass("testTxt2VidWithOptions passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidWithOptions failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidObjectSyntax",
|
||||
description: "Test txt2vid with single options object containing prompt",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidObjectSyntaxCore();
|
||||
pass("testTxt2VidObjectSyntax passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidObjectSyntax failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPath",
|
||||
description: "Test that puter_output_path writes the generated video to the Puter filesystem (relative path)",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathCore();
|
||||
pass("testTxt2VidPuterOutputPath passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPath failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathAbsolute",
|
||||
description: "Test puter_output_path with an absolute path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathAbsoluteCore();
|
||||
pass("testTxt2VidPuterOutputPathAbsolute passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathAbsolute failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathHomeTilde",
|
||||
description: "Test puter_output_path with a ~/... home-relative path",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathHomeTildeCore();
|
||||
pass("testTxt2VidPuterOutputPathHomeTilde passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathHomeTilde failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "testTxt2VidPuterOutputPathPermissionDenied",
|
||||
description: "Test that writing to a path without permission surfaces the real backend error",
|
||||
test: async function() {
|
||||
try {
|
||||
await testTxt2VidPuterOutputPathPermissionDeniedCore();
|
||||
pass("testTxt2VidPuterOutputPathPermissionDenied passed");
|
||||
} catch (error) {
|
||||
fail("testTxt2VidPuterOutputPathPermissionDenied failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copy this file to `.env` (in the same folder) and fill in your local values.
|
||||
# `.env` is gitignored — never commit real credentials.
|
||||
|
||||
# Required: admin password from your local Puter.
|
||||
# When you run `npm start` from the monorepo root, Puter prints something like:
|
||||
# ************************************************************
|
||||
# * Your default login credentials are:
|
||||
# * Username: admin
|
||||
# * Password: <hex string>
|
||||
# ************************************************************
|
||||
# Copy that password here.
|
||||
PUTER_ADMIN_PASSWORD=
|
||||
|
||||
# Optional overrides (defaults shown).
|
||||
# PUTER_ADMIN_USERNAME=admin
|
||||
# PUTER_TEST_ORIGIN=http://puter.localhost:4100
|
||||
# PUTER_TEST_FIXTURE_ORIGIN=http://localhost:8080
|
||||
@@ -0,0 +1 @@
|
||||
.auth/
|
||||
@@ -0,0 +1,16 @@
|
||||
# puter-js end-to-end tests
|
||||
|
||||
See **[../../TESTING.md](../../TESTING.md)** for the full guide: setup, how to set the admin password, how to run the tests, how it all works, and how to add new tests.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```sh
|
||||
# one-time setup
|
||||
cp tests/e2e/.env.example tests/e2e/.env
|
||||
$EDITOR tests/e2e/.env # set PUTER_ADMIN_PASSWORD from the npm start banner
|
||||
|
||||
# run
|
||||
npm run test:e2e # headless
|
||||
npm run test:e2e:headed # watch the browser
|
||||
npm run test:e2e:record # save video for every test
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>puter-js test fixture: menubar + contextMenu</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; padding: 16px; }
|
||||
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
|
||||
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
|
||||
.ready #status { color: green; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>puter-js test fixture</h1>
|
||||
<p id="status">Loading puter.js…</p>
|
||||
|
||||
<button id="set-menubar-btn" disabled>Set Menubar</button>
|
||||
<button id="ctx-trigger" disabled>Show Context Menu</button>
|
||||
|
||||
<div id="log" data-testid="log"></div>
|
||||
|
||||
<script src="/dist/puter.dev.js"></script>
|
||||
<script>
|
||||
const logEl = document.getElementById('log');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
function log(entry) {
|
||||
const line = document.createElement('div');
|
||||
line.textContent = entry;
|
||||
line.dataset.entry = entry;
|
||||
logEl.appendChild(line);
|
||||
}
|
||||
|
||||
function ready() {
|
||||
document.body.classList.add('ready');
|
||||
statusEl.textContent = `Ready (env=${puter.env})`;
|
||||
document.getElementById('set-menubar-btn').disabled = false;
|
||||
document.getElementById('ctx-trigger').disabled = false;
|
||||
}
|
||||
|
||||
function waitForPuter() {
|
||||
if (window.puter && puter.ui) ready();
|
||||
else setTimeout(waitForPuter, 50);
|
||||
}
|
||||
waitForPuter();
|
||||
|
||||
document.getElementById('set-menubar-btn').addEventListener('click', () => {
|
||||
puter.ui.setMenubar({
|
||||
items: [
|
||||
{ label: 'TestFile', action: () => log('menubar:TestFile') },
|
||||
{ label: 'TestEdit', items: [
|
||||
{ label: 'TestUndo', action: () => log('menubar:TestUndo') },
|
||||
{ label: 'TestRedo', action: () => log('menubar:TestRedo') },
|
||||
]},
|
||||
],
|
||||
});
|
||||
log('menubar:set');
|
||||
});
|
||||
|
||||
document.getElementById('ctx-trigger').addEventListener('click', () => {
|
||||
puter.ui.contextMenu({
|
||||
items: [
|
||||
{ label: 'CtxAlpha', action: () => log('ctx:CtxAlpha') },
|
||||
{ label: 'CtxBeta', action: () => log('ctx:CtxBeta') },
|
||||
'-',
|
||||
{ label: 'CtxGamma', action: () => log('ctx:CtxGamma') },
|
||||
],
|
||||
});
|
||||
log('ctx:opened');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORAGE_STATE_PATH = path.join(__dirname, '.auth', 'state.json');
|
||||
const ENV_FILE_PATH = path.join(__dirname, '.env');
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
// Tiny dotenv loader. Loads tests/e2e/.env into process.env if it exists.
|
||||
// Existing process.env wins, so CLI overrides still work.
|
||||
async function loadDotEnv (filePath) {
|
||||
if ( ! existsSync(filePath) ) return;
|
||||
const text = await readFile(filePath, 'utf8');
|
||||
for ( const rawLine of text.split('\n') ) {
|
||||
const line = rawLine.trim();
|
||||
if ( !line || line.startsWith('#') ) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if ( eq < 0 ) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if ( ! (key in process.env) ) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function isCachedStateFresh () {
|
||||
if ( ! existsSync(STORAGE_STATE_PATH) ) return false;
|
||||
if ( process.env.PUTER_TEST_RESET_AUTH === '1' ) return false;
|
||||
try {
|
||||
const s = await stat(STORAGE_STATE_PATH);
|
||||
return (Date.now() - s.mtimeMs) < CACHE_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAsAdmin ({ origin, username, password }) {
|
||||
// Puter rejects POSTs without an Origin header (403 Forbidden), so send
|
||||
// one matching the target origin to satisfy its CSRF check.
|
||||
const res = await fetch(`${origin}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': origin,
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if ( ! res.ok ) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`POST /login → HTTP ${res.status}: ${text || '(empty body)'}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if ( ! data.token ) {
|
||||
throw new Error(`POST /login response had no token: ${JSON.stringify(data)}`);
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
export default async function globalSetup () {
|
||||
await loadDotEnv(ENV_FILE_PATH);
|
||||
|
||||
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
|
||||
const ADMIN_USERNAME = process.env.PUTER_ADMIN_USERNAME || 'admin';
|
||||
const ADMIN_PASSWORD = process.env.PUTER_ADMIN_PASSWORD || '';
|
||||
|
||||
await mkdir(path.dirname(STORAGE_STATE_PATH), { recursive: true });
|
||||
|
||||
if ( await isCachedStateFresh() ) {
|
||||
|
||||
console.log(`[e2e] Reusing cached auth state at ${STORAGE_STATE_PATH}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! ADMIN_PASSWORD ) {
|
||||
throw new Error(
|
||||
'PUTER_ADMIN_PASSWORD is not set.\n' +
|
||||
'Your local Puter prints the admin password at startup (look for "Password: <hex>" in the monorepo\'s npm start output).\n' +
|
||||
'Set it in tests/e2e/.env (gitignored):\n' +
|
||||
' cp tests/e2e/.env.example tests/e2e/.env\n' +
|
||||
' # then edit tests/e2e/.env and set PUTER_ADMIN_PASSWORD\n' +
|
||||
'Or pass it inline: PUTER_ADMIN_PASSWORD=<value> npm run test:e2e',
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Direct API login → token (no browser, no /signup, no rate-limit risk).
|
||||
const token = await loginAsAdmin({ origin: PUTER_ORIGIN, username: ADMIN_USERNAME, password: ADMIN_PASSWORD });
|
||||
|
||||
// 2) Open Puter with ?auth_token=<token>&api_origin=<origin> so initgui's
|
||||
// auth_token flow runs the full setup (puter.setAuthToken,
|
||||
// puter.setAPIOrigin, /session/sync-cookie, update_auth_data) and persists
|
||||
// the API origin to localStorage.api_origin — otherwise the bundled SDK
|
||||
// keeps its production default (https://api.puter.com) and apps.create
|
||||
// hits the wrong server.
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Probe the GUI's templated api_origin first so we can pass it through.
|
||||
await page.goto(PUTER_ORIGIN);
|
||||
const apiOrigin = await page.evaluate(() => window.api_origin || null);
|
||||
if ( ! apiOrigin ) {
|
||||
await browser.close();
|
||||
throw new Error(`Could not determine api_origin from ${PUTER_ORIGIN} (window.api_origin was empty).`);
|
||||
}
|
||||
|
||||
await page.goto(
|
||||
`${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}` +
|
||||
`&api_origin=${encodeURIComponent(apiOrigin)}`,
|
||||
);
|
||||
|
||||
await page.waitForFunction(() => !!window.puter, null, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const ls = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
|
||||
const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
|
||||
return !!(ls && lsApi && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === lsApi);
|
||||
},
|
||||
null,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
await context.storageState({ path: STORAGE_STATE_PATH });
|
||||
await browser.close();
|
||||
|
||||
console.log(`[e2e] Logged in as "${ADMIN_USERNAME}", saved auth state to ${STORAGE_STATE_PATH}`);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const FIXTURE_ORIGIN = process.env.PUTER_TEST_FIXTURE_ORIGIN || 'http://localhost:8080';
|
||||
const FIXTURE_PATH = '/tests/e2e/fixtures/menubar-contextmenu.html';
|
||||
|
||||
export const FIXTURE_URL = `${FIXTURE_ORIGIN}${FIXTURE_PATH}`;
|
||||
|
||||
const PUTER_READY_TIMEOUT = 60_000;
|
||||
|
||||
/**
|
||||
* Waits for Puter desktop to be fully signed in (auto-temp-user creation done)
|
||||
* and the puter.js SDK to be authenticated. Throws on timeout with a useful
|
||||
* snapshot of why we think it's not ready.
|
||||
*/
|
||||
export async function waitForPuterReady (page) {
|
||||
await page.waitForFunction(() => !!window.puter, null, { timeout: PUTER_READY_TIMEOUT });
|
||||
|
||||
// With storageState, sign-in should already be done. Wait for both auth
|
||||
// and the API origin to be picked up by the SDK from localStorage.
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const ls = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
|
||||
const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
|
||||
return !!(
|
||||
ls && lsApi &&
|
||||
window.auth_token && window.puter?.authToken &&
|
||||
window.puter?.APIOrigin === lsApi
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: PUTER_READY_TIMEOUT },
|
||||
);
|
||||
} catch (e) {
|
||||
const diag = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
puter: typeof window.puter,
|
||||
puterAuthToken: !!window.puter?.authToken,
|
||||
windowAuthToken: !!window.auth_token,
|
||||
lsAuthToken: !!(typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')),
|
||||
firstVisitEver: window.first_visit_ever,
|
||||
isAuth: typeof window.is_auth === 'function' ? window.is_auth() : null,
|
||||
user: window.user ? { is_temp: window.user.is_temp, username: window.user.username } : null,
|
||||
captchaModalVisible: document.querySelector('.captcha-modal') ? true : false,
|
||||
}));
|
||||
throw new Error(`waitForPuterReady timed out. Diagnostics: ${JSON.stringify(diag, null, 2)}`);
|
||||
}
|
||||
|
||||
await page.waitForFunction(() => !!window.puter?.apps?.create, null, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function assertFixtureReachable (fixtureURL) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(fixtureURL, { method: 'GET' });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Fixture URL is unreachable: ${fixtureURL}\n` +
|
||||
'→ Is the puter-js dev server running? From src/puter-js: `npm start`\n' +
|
||||
`Underlying error: ${e?.message || e}`,
|
||||
);
|
||||
}
|
||||
if ( ! res.ok ) {
|
||||
throw new Error(`Fixture URL returned HTTP ${res.status}: ${fixtureURL}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerTestApp (page, { fixtureURL = FIXTURE_URL } = {}) {
|
||||
await assertFixtureReachable(fixtureURL);
|
||||
await page.goto('/');
|
||||
await waitForPuterReady(page);
|
||||
|
||||
const appName = `puter-js-testing-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const result = await page.evaluate(
|
||||
async ({ name, url }) => {
|
||||
const ctx = {
|
||||
APIOrigin: window.puter?.APIOrigin,
|
||||
authTokenLen: window.puter?.authToken ? window.puter.authToken.length : 0,
|
||||
api_origin: window.api_origin,
|
||||
};
|
||||
try {
|
||||
const app = await window.puter.apps.create(name, url);
|
||||
return { ok: true, app, ctx };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message || (typeof e === 'object' ? JSON.stringify(e) : e)), ctx };
|
||||
}
|
||||
},
|
||||
{ name: appName, url: fixtureURL },
|
||||
);
|
||||
if ( ! result.ok ) {
|
||||
throw new Error(`puter.apps.create failed: ${result.error}\nContext: ${JSON.stringify(result.ctx)}`);
|
||||
}
|
||||
|
||||
return appName;
|
||||
}
|
||||
|
||||
export async function deleteTestApp (page, appName) {
|
||||
if ( ! appName ) return;
|
||||
try {
|
||||
await page.goto('/');
|
||||
await waitForPuterReady(page);
|
||||
await page.evaluate(async (name) => {
|
||||
try {
|
||||
await window.puter.apps.delete(name);
|
||||
} catch {
|
||||
}
|
||||
}, appName);
|
||||
} catch {
|
||||
// Cleanup is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export async function gotoTestApp (page, appName) {
|
||||
await page.goto(`/app/${appName}`);
|
||||
const appFrame = page.frameLocator('iframe.window-app-iframe').last();
|
||||
await appFrame.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
return appFrame;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { registerTestApp, deleteTestApp, gotoTestApp } from '../helpers/testApp.js';
|
||||
|
||||
test.describe('puter.ui.contextMenu (env=app)', () => {
|
||||
test('renders context menu and fires action when item is clicked', async ({ page }) => {
|
||||
const appName = await registerTestApp(page);
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#ctx-trigger').click();
|
||||
|
||||
const menu = page.locator('.context-menu:visible').last();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const itemAlpha = menu.locator('.context-menu-item', { hasText: 'CtxAlpha' });
|
||||
const itemBeta = menu.locator('.context-menu-item', { hasText: 'CtxBeta' });
|
||||
await expect(itemAlpha).toBeVisible();
|
||||
await expect(itemBeta).toBeVisible();
|
||||
|
||||
// This is the regression test for commit aa5e398e: on mobile viewports the
|
||||
// sheet backdrop must sit BELOW the menu so taps reach the items. If the
|
||||
// z-index regresses, the backdrop swallows this click and the action never
|
||||
// fires, leaving the log empty.
|
||||
await itemAlpha.click();
|
||||
await expect(appFrame.locator('#log [data-entry="ctx:CtxAlpha"]')).toBeVisible();
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking outside the context menu dismisses it', async ({ page }) => {
|
||||
const appName = await registerTestApp(page);
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#ctx-trigger').click();
|
||||
const menu = page.locator('.context-menu:visible').last();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Click somewhere far from the menu.
|
||||
await page.mouse.click(5, 5);
|
||||
await expect(menu).toBeHidden();
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { registerTestApp, deleteTestApp, gotoTestApp } from '../helpers/testApp.js';
|
||||
|
||||
test.describe('puter.ui.setMenubar (env=app)', () => {
|
||||
test('renders menubar items in Puter desktop and fires action on click', async ({ page }) => {
|
||||
const appName = await registerTestApp(page);
|
||||
try {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#set-menubar-btn').click();
|
||||
|
||||
// Menubar is rendered by Puter desktop in the parent frame, not the app iframe.
|
||||
const menubar = page.locator('.window-menubar:visible');
|
||||
await expect(menubar).toBeVisible();
|
||||
|
||||
const fileItem = menubar.locator('.window-menubar-item', { hasText: 'TestFile' });
|
||||
const editItem = menubar.locator('.window-menubar-item', { hasText: 'TestEdit' });
|
||||
await expect(fileItem).toBeVisible();
|
||||
await expect(editItem).toBeVisible();
|
||||
|
||||
// Top-level item with a direct action: click should fire it.
|
||||
await fileItem.click();
|
||||
await expect(appFrame.locator('#log [data-entry="menubar:TestFile"]')).toBeVisible();
|
||||
|
||||
// Top-level item with subitems: click opens a dropdown context menu.
|
||||
await editItem.click();
|
||||
const dropdown = page.locator('.context-menu:visible').last();
|
||||
await expect(dropdown).toBeVisible();
|
||||
await expect(dropdown.locator('.context-menu-item', { hasText: 'TestUndo' })).toBeVisible();
|
||||
await dropdown.locator('.context-menu-item', { hasText: 'TestUndo' }).click();
|
||||
await expect(appFrame.locator('#log [data-entry="menubar:TestUndo"]')).toBeVisible();
|
||||
} finally {
|
||||
await deleteTestApp(page, appName);
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+2
@@ -77,6 +77,7 @@ export interface Txt2ImgOptions {
|
||||
disable_safety_checker?: boolean;
|
||||
response_format?: string;
|
||||
test_mode?: boolean;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface Txt2VidOptions {
|
||||
@@ -106,6 +107,7 @@ export interface Txt2VidOptions {
|
||||
reference_images?: string[];
|
||||
frame_images?: Array<{ input_image: string; frame: number }>;
|
||||
metadata?: Record<string, unknown>;
|
||||
puter_output_path?: string;
|
||||
}
|
||||
|
||||
export interface Txt2SpeechOptions {
|
||||
|
||||
Reference in new issue
Block a user