Add k6 performance test suite for backend

- run.sh CLI orchestrator with per-script defaults and env-var override
- Shared penpot-client.js library (JSON RPC, cookie auth, tagged metrics)
- Scripts: lifecycle, workspace-open, workspace-edit, concurrent-edit,
  media-upload, font-upload, file-size-matrix, compare-results
- Concurrent-edit supports same-file and multi-file modes via shared teams
- CI workflow (perf-regression) comparing baseline vs PR branch
- k6 binary installed in devenv Dockerfile
This commit is contained in:
Andrey Antukh 2026-06-12 11:42:15 +02:00
parent 28f3b8048a
commit 05fa07f911
21 changed files with 3926 additions and 9 deletions

201
.github/workflows/perf-regression.yml vendored Normal file
View File

@ -0,0 +1,201 @@
name: "CI: Performance Regression"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/src/**'
- 'common/src/**'
types:
- opened
- synchronize
- ready_for_review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
perf-regression:
if: ${{ !github.event.pull_request.draft }}
name: "Performance Regression Check"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: penpot
POSTGRES_PASSWORD: penpot
POSTGRES_DB: penpot
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
env:
PENPOT_DATABASE_URI: "postgresql://postgres/penpot"
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
PENPOT_REDIS_URI: "redis://redis/1"
PENPOT_FLAGS: "enable-demo-users enable-backend-api-doc"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install k6
run: |
curl -sSL https://dl.k6.io/key.gpg | gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
apt-get update
apt-get install -y k6
- name: Save performance suite
run: cp -r backend/performance /tmp/performance
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: |
~/.m2
~/.gitlibs
key: ${{ runner.os }}-m2-${{ hashFiles('backend/deps.edn', 'common/deps.edn') }}
restore-keys: |
${{ runner.os }}-m2-
# -------------------------------------------------------------------------
# Run performance tests on BASE branch (before change)
# -------------------------------------------------------------------------
- name: Checkout base branch
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}
git checkout origin/${{ github.event.pull_request.base.ref }}
- name: Restore performance suite (base)
run: cp -r /tmp/performance backend/performance
- name: Start backend (base branch)
working-directory: backend
run: |
clojure -M:dev -m app.main &
# Wait for backend to be ready
for i in $(seq 1 30); do
if curl -s http://localhost:6060/api/rpc/command/get-profile > /dev/null 2>&1; then
echo "Backend ready"
break
fi
echo "Waiting for backend... ($i/30)"
sleep 2
done
- name: Run performance tests (baseline)
working-directory: backend/performance
run: |
mkdir -p results/baseline
./run.sh smoke
./run.sh lifecycle -v 5 -n 10
cp -r results/latest/* results/baseline/ 2>/dev/null || true
- name: Save baseline results
run: cp -r backend/performance/results /tmp/results-baseline
- name: Stop backend
run: |
pkill -f "app.main" || true
sleep 2
- name: Clean untracked files
run: git clean -fd
# -------------------------------------------------------------------------
# Run performance tests on PR branch (after change)
# -------------------------------------------------------------------------
- name: Checkout PR branch
run: |
git checkout ${{ github.event.pull_request.head.sha }}
- name: Restore baseline results
run: cp -r /tmp/results-baseline backend/performance/results
- name: Start backend (PR branch)
working-directory: backend
run: |
clojure -M:dev -m app.main &
# Wait for backend to be ready
for i in $(seq 1 30); do
if curl -s http://localhost:6060/api/rpc/command/get-profile > /dev/null 2>&1; then
echo "Backend ready"
break
fi
echo "Waiting for backend... ($i/30)"
sleep 2
done
- name: Run performance tests (current)
working-directory: backend/performance
run: |
mkdir -p results/current
./run.sh smoke
./run.sh lifecycle -v 5 -n 10
# Copy results
cp -r results/latest/* results/current/ 2>/dev/null || true
- name: Stop backend
run: |
pkill -f "app.main" || true
sleep 2
# -------------------------------------------------------------------------
# Compare results
# -------------------------------------------------------------------------
- name: Compare results
working-directory: backend/performance
run: |
BASELINE=$(find results/baseline -name "k6-summary.json" | head -1)
CURRENT=$(find results/current -name "k6-summary.json" | head -1)
if [ -z "$BASELINE" ] || [ -z "$CURRENT" ]; then
echo "Warning: Could not find k6 summary files"
echo "Baseline: $BASELINE"
echo "Current: $CURRENT"
exit 0
fi
echo "Comparing:"
echo " Baseline: $BASELINE"
echo " Current: $CURRENT"
echo ""
node scripts/compare-results.cjs "$BASELINE" "$CURRENT" --threshold 20
# -------------------------------------------------------------------------
# Upload artifacts
# -------------------------------------------------------------------------
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-results
path: backend/performance/results/
retention-days: 30

2
.gitignore vendored
View File

@ -48,6 +48,7 @@
/backend/target/
/backend/experiments
/backend/scripts/_env.local
/backend/performance/results/
/bundle*
/clj-profiler/
/common/coverage
@ -100,3 +101,4 @@
/opencode.json
/.codex/
/tools/__pycache__
/performance/results/

View File

@ -0,0 +1,128 @@
# Penpot Performance Tests
k6-based load and performance test suite for the Penpot backend. Measures HTTP RPC latency, throughput, and error rates under synthetic user load.
## Prerequisites
- **k6** — Install from https://k6.io/docs/get-started/installation/ (also included in `devenv` image)
- **Running Penpot backend** — Local devenv (`http://localhost:6060`) or a remote instance
## Quick Start
```bash
# Smoke test — 1 VU, 1 iteration, demo mode
./run.sh smoke
# Full lifecycle with 10 VUs, 5 iterations each
./run.sh lifecycle -v 10 -n 5
# Use registration flow instead of demo profiles
./run.sh lifecycle -m register -v 5 -n 1
# Point to a remote backend
./run.sh lifecycle -u https://penpot.example.com
# Show all options
./run.sh help
```
## Commands
| Command | Description |
|---|---|
| `smoke` | 1 VU, 1 iteration smoke test of the lifecycle flow |
| `lifecycle` | Full user lifecycle (register → CRUD → delete) |
| `workspace-open` | Read-heavy: repeatedly open a file (get-file, libraries, thumbnails) |
| `workspace-edit` | Write-heavy: repeatedly edit a file (get-file + update-file loop) |
| `media-upload` | Upload images of varying sizes (direct + chunked) |
| `font-upload` | Upload fonts via chunked upload + create-font-variant |
| `concurrent-edit` | Concurrent editing: same-file or multi-file mode |
| `file-size-matrix` | Measure latency vs file size (10, 100, 500, 1000 shapes) |
| `compare` | Compare two k6 JSON results for regression |
| `all` | Run all scenarios together (orchestrator) |
| `clean` | Remove test results |
## Options
| Flag | Env Variable | Default | Description |
|---|---|---|---|
| `-u URL` | `PENPOT_BASE_URL` | `http://localhost:6060` | Penpot backend URL |
| `-v NUM` | — | per-script default | Number of virtual users |
| `-n NUM` | — | per-script default | k6 iterations |
| `-d DUR` | `PENPOT_DURATION` | k6 default | Test duration (e.g. `30s`, `5m`, `2h`) |
| `-m MODE` | `PENPOT_REGISTER_MODE` | `demo` | Register mode: `demo` or `register` |
| `-k PATH` | `K6` | `k6` | Path to k6 binary |
### Concurrent-edit / file-size-matrix options
| Flag | Env Variable | Default | Description |
|---|---|---|---|
| `--mode MODE` | `PENPOT_EDIT_MODE` | `same-file` | `same-file` or `multi-file` |
| `--files NUM` | `PENPOT_FILE_COUNT` | `1` | Number of files for multi-file mode |
| `--vus-per-file NUM` | `PENPOT_VUS_PER_FILE` | `1` | VUs per file for multi-file mode |
| `--edit-iterations NUM` | `PENPOT_EDIT_ITERATIONS` | `10` | Per-VU edit loop iterations |
`--edit-iterations` controls the per-VU edit loop in both `concurrent-edit` and `file-size-matrix`. It is **independent** of `-n` (which controls k6's shared-iterations executor).
### Register Modes
- **`demo`** (default): Uses the `create-demo-profile` RPC endpoint. Requires the `demo-users` feature flag to be enabled on the backend. Fastest for testing.
- **`register`**: Uses the full two-step registration flow (`prepare-register-profile` + `register-profile`). Works without any feature flags but is slower.
## Examples
```bash
# Same-file concurrent edit: 5 VUs editing the same file
./run.sh concurrent-edit --mode same-file -v 5 -n 10 --edit-iterations 20
# Multi-file concurrent edit: 3 files, 4 VUs each
./run.sh concurrent-edit --mode multi-file --files 3 --vus-per-file 4 -n 10
# File size matrix: 50 iterations per size tier
./run.sh file-size-matrix --edit-iterations 50
# Duration-based test: 5 VUs for 30 seconds
./run.sh lifecycle -v 5 -d 30s
# Run all scenarios with 50 VUs
./run.sh all -v 50
# Compare baseline vs current results
./run.sh compare results/baseline/20250625-120000-lifecycle/k6-summary.json \
results/current/20250625-130000-lifecycle/k6-summary.json
```
## Shared Client (`lib/penpot-client.js`)
The shared client module wraps the Penpot backend RPC API using plain JSON (not Transit). Key features:
- **JSON transport**: Uses `Content-Type: application/json` for POST bodies and `Accept: application/json` (or `_fmt=json` for GET) for responses.
- **Cookie-based auth**: k6 automatically manages session cookies per VU.
- **Session headers**: Generates `x-session-id` and `x-external-session-id` UUIDs per VU.
- **Tagged metrics**: Every request is tagged with `rpc_command` for k6 metric slicing.
## Results
Test results are written to `results/<timestamp>/` as JSON. k6 also prints a summary to stdout with percentile breakdowns per RPC command.
## Thresholds
Each script includes built-in thresholds that cause k6 to exit with a non-zero code if exceeded:
- `http_req_duration p95 < 5000ms` (global)
- `http_req_failed < 1%` (global)
- Per-command thresholds for login, profile, project, file, and update operations
## Adding New Flows
1. Create `scripts/<flow-name>.js`
2. Import the shared client: `import { createClient } from "../lib/penpot-client.js";`
3. Implement the flow using the client methods
4. Add a command in `run.sh`
## Architecture Notes
- The backend supports both Transit JSON and plain JSON. This test suite uses **plain JSON** for simplicity (no Transit encoder needed in k6).
- JSON request keys are in **kebab-case** (matching Clojure conventions). JSON response keys are in **camelCase** (backend's default JSON encoding).
- `update-file` sends the `id` parameter both in the query string and in the POST body, matching the frontend's behavior.
- The backend uses optimistic concurrency control (`revn`) for file updates. The test retries once on conflict.

View File

@ -0,0 +1,621 @@
// Penpot k6 HTTP Client
//
// Shared module that wraps the Penpot backend RPC API using plain JSON.
// The backend supports `application/json` request bodies (kebab-case keys)
// and `application/json` responses (camelCase keys) via Accept header or _fmt=json.
//
// Authentication is cookie-based: login-with-password sets a session cookie,
// and all subsequent requests include it automatically via the k6 cookie jar.
import http from "k6/http";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/**
* Creates a new Penpot client instance.
*
* @param {string} baseUrl - The base URL of the Penpot backend (e.g., "http://localhost:6060")
* @returns {object} Client instance with RPC methods
*/
export function createClient(baseUrl) {
// Per-VU session identifiers — consistent across all requests within one VU iteration
const sessionId = uuidv4();
const externalSessionId = uuidv4();
const defaultHeaders = {
"Accept": "application/json",
"x-session-id": sessionId,
"x-external-session-id": externalSessionId,
"x-event-origin": "perf-test",
"x-client": "penpot-perf/1.0",
};
// k6 automatically manages cookies per VU when `cookies` are returned by the server.
// We use the default cookie jar which is per-VU.
/**
* Make an RPC call to the Penpot backend.
*
* GET requests: params go as query parameters, response is JSON via _fmt=json.
* POST requests: params go as JSON body, response is JSON via Accept header.
*
* @param {string} method - HTTP method ("GET" or "POST")
* @param {string} command - RPC command name (e.g., "login-with-password")
* @param {object} params - Parameters for the RPC call
* @param {object} [opts] - Additional options
* @param {string} [opts.tag] - k6 metric tag for this request
* @returns {object} k6 Response object with parsed JSON body
*/
function rpc(method, command, params = {}, opts = {}) {
const url = `${baseUrl}/api/main/methods/${command}`;
const tag = opts.tag || command;
const tags = {
rpc_command: tag,
};
if (method === "GET") {
// GET requests: params go as query string, add _fmt=json for JSON response
const queryParams = { ...params, _fmt: "json" };
const qs = Object.entries(queryParams)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const fullUrl = qs ? `${url}?${qs}` : url;
return http.get(fullUrl, {
headers: defaultHeaders,
tags,
});
} else {
// POST requests: params go as JSON body
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
return http.post(url, JSON.stringify(params), {
headers,
tags,
});
}
}
/**
* Login with email and password.
* Returns the profile data on success. The session cookie is stored
* automatically by k6's cookie jar.
*
* @param {string} email
* @param {string} password
* @returns {object} Parsed response { status, body }
*/
function login(email, password) {
const res = rpc("POST", "login-with-password", {
email,
password,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get the current user's profile (requires prior login).
*
* @returns {object} Parsed response { status, body }
*/
function getProfile() {
const res = rpc("GET", "get-profile");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get all teams for the current user.
*
* @returns {object} Parsed response { status, body }
*/
function getTeams() {
const res = rpc("GET", "get-teams");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new team.
*
* @param {string} name - Team name
* @returns {object} Parsed response { status, body }
*/
function createTeam(name) {
const res = rpc("POST", "create-team", { name });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get projects for a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status, body }
*/
function getProjects(teamId) {
const res = rpc("GET", "get-projects", { "team-id": teamId });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new project.
*
* @param {string} teamId - Team UUID
* @param {string} name - Project name
* @returns {object} Parsed response { status, body }
*/
function createProject(teamId, name) {
const res = rpc("POST", "create-project", {
"team-id": teamId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new file in a project.
*
* @param {string} projectId - Project UUID
* @param {string} name - File name
* @returns {object} Parsed response { status, body }
*/
function createFile(projectId, name) {
const res = rpc("POST", "create-file", {
"project-id": projectId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get a file by ID.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFile(fileId) {
const res = rpc("GET", "get-file", {
id: fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get libraries used by a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileLibraries(fileId) {
const res = rpc("GET", "get-file-libraries", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get object thumbnails for a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileObjectThumbnails(fileId) {
const res = rpc("GET", "get-file-object-thumbnails", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get file data for thumbnail generation.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileDataForThumbnail(fileId) {
const res = rpc("GET", "get-file-data-for-thumbnail", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Update a file with changes.
*
* The backend uses optimistic concurrency control via `revn`.
* If a conflict occurs (status 400 with :revn-conflict), the caller
* should retry with the latest revn from getFile().
*
* @param {string} fileId - File UUID
* @param {number} revn - Current file revision number
* @param {number} vern - Current file version number
* @param {string} sessionId - Client session ID (UUID)
* @param {Array} changes - Array of change objects
* @returns {object} Parsed response { status, body }
*/
function updateFile(fileId, revn, vern, changesSessionId, changes) {
const params = {
id: fileId,
revn: revn,
vern: vern,
"session-id": changesSessionId,
origin: "workspace",
"created-at": new Date().toISOString(),
"commit-id": uuidv4(),
changes: changes,
};
// update-file uses POST with id also as query param (per frontend convention)
const url = `${baseUrl}/api/main/methods/update-file?id=${encodeURIComponent(fileId)}`;
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
const res = http.post(url, JSON.stringify(params), {
headers,
tags: { rpc_command: "update-file" },
});
let body = null;
try {
if (res.body && res.body.length > 0) {
body = res.json();
}
} catch (e) {
// body may not be JSON
}
return {
status: res.status,
body: body,
raw: res,
};
}
/**
* Upload a file media object using direct multipart upload.
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-file-media-object`;
const headers = {
...defaultHeaders,
// No Content-Type — k6 sets it automatically for multipart/form-data
};
const formData = {
"file-id": fileId,
"is-local": "true",
name: fileName,
content: http.file(fileBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-file-media-object" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Chunked upload
// -----------------------------------------------------------------------
/**
* Create an upload session for chunked uploads.
*
* @param {number} totalChunks - Number of chunks
* @returns {object} { status, sessionId }
*/
function createUploadSession(totalChunks) {
const res = rpc("POST", "create-upload-session", {
"total-chunks": totalChunks,
});
const body = res.status === 200 ? res.json() : null;
return {
status: res.status,
sessionId: body ? body.sessionId : null,
raw: res,
};
}
/**
* Upload a single chunk within an upload session.
*
* @param {string} sessionId - Upload session UUID
* @param {number} index - Chunk index (0-based)
* @param {Uint8Array} chunkBytes - The chunk content
* @param {string} fileName - Original file name
* @param {string} mimeType - MIME type
* @returns {object} Parsed response { status, body }
*/
function uploadChunk(sessionId, index, chunkBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-chunk`;
const headers = {
...defaultHeaders,
};
const formData = {
"session-id": sessionId,
index: String(index),
content: http.file(chunkBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-chunk" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Assemble all uploaded chunks into a final media object.
*
* @param {string} sessionId - Upload session UUID
* @param {string} fileId - File UUID
* @param {string} name - Media object name
* @param {boolean} isLocal - Whether the object is local to the file
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function assembleFileMediaObject(sessionId, fileId, name, isLocal, mimeType) {
const res = rpc("POST", "assemble-file-media-object", {
"session-id": sessionId,
"file-id": fileId,
name: name,
"is-local": isLocal,
mtype: mimeType,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Smart upload — picks direct or chunked based on file size
// -----------------------------------------------------------------------
// Chunk size threshold: files larger than this use chunked upload.
// The actual chunk size is irrelevant to the backend; this controls
// which upload path is exercised.
const CHUNK_SIZE = 50 * 1024; // 50 KB
/**
* Upload a file media object, automatically selecting direct or chunked
* upload based on file size.
*
* Files <= CHUNK_SIZE use direct multipart upload.
* Files > CHUNK_SIZE use chunked upload (create-upload-session
* upload-chunk × N assemble-file-media-object).
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObject(fileId, fileBytes, fileName, mimeType) {
if (fileBytes.byteLength <= CHUNK_SIZE) {
return uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType);
}
// Chunked upload path
const totalChunks = Math.ceil(fileBytes.byteLength / CHUNK_SIZE);
const sessionRes = createUploadSession(totalChunks);
if (sessionRes.status !== 200) {
return { status: sessionRes.status, body: null, raw: sessionRes.raw };
}
const uploadSessionId = sessionRes.sessionId;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileBytes.byteLength);
const chunk = fileBytes.slice(start, end);
const chunkRes = uploadChunk(uploadSessionId, i, chunk, fileName, mimeType);
if (chunkRes.status !== 200) {
return { status: chunkRes.status, body: null, raw: chunkRes.raw };
}
}
return assembleFileMediaObject(uploadSessionId, fileId, fileName, true, mimeType);
}
/**
* Delete a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status }
*/
function deleteFile(fileId) {
const res = rpc("POST", "delete-file", { id: fileId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a project.
*
* @param {string} projectId - Project UUID
* @returns {object} Parsed response { status }
*/
function deleteProject(projectId) {
const res = rpc("POST", "delete-project", { id: projectId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status }
*/
function deleteTeam(teamId) {
const res = rpc("POST", "delete-team", { id: teamId });
return {
status: res.status,
raw: res,
};
}
/**
* Invite members to a team by email.
*
* @param {string} teamId - Team UUID
* @param {string[]} emails - Array of email addresses
* @param {string} role - Role for the invited members (e.g. "editor")
* @returns {object} Parsed response { status, body }
*/
function inviteTeamMembers(teamId, emails, role) {
const res = rpc("POST", "create-team-invitations", {
"team-id": teamId,
emails: emails,
role: role,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get an invitation token for a specific email.
*
* @param {string} teamId - Team UUID
* @param {string} email - Invited email address
* @returns {object} Parsed response { status, body }
*/
function getTeamInvitationToken(teamId, email) {
const res = rpc("GET", "get-team-invitation-token", {
"team-id": teamId,
email: email,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Logout the current user.
*
* @param {string} profileId - Profile UUID
* @returns {object} Parsed response { status }
*/
function logout(profileId) {
const res = rpc("POST", "logout", { "profile-id": profileId });
return {
status: res.status,
raw: res,
};
}
// Return the client interface
return {
sessionId,
externalSessionId,
rpc,
login,
getProfile,
getTeams,
createTeam,
getProjects,
createProject,
createFile,
getFile,
getFileLibraries,
getFileObjectThumbnails,
getFileDataForThumbnail,
updateFile,
uploadFileMediaObject,
uploadFileMediaObjectDirect,
createUploadSession,
uploadChunk,
assembleFileMediaObject,
deleteFile,
deleteProject,
deleteTeam,
inviteTeamMembers,
getTeamInvitationToken,
logout,
};
}

444
backend/performance/run.sh Executable file
View File

@ -0,0 +1,444 @@
#!/usr/bin/env bash
#
# Penpot Performance Tests
#
# k6-based load/performance test suite for the Penpot backend.
#
# Prerequisites:
# - k6 (https://k6.io/) installed and in PATH
# - A running Penpot backend (local devenv or remote)
#
# Usage:
# ./run.sh smoke # 1 VU, 1 iteration smoke test
# ./run.sh lifecycle # Full user lifecycle
# ./run.sh workspace-open # Read-heavy file open flow
# ./run.sh workspace-edit # Write-heavy file edit loop
# ./run.sh media-upload # Direct + chunked image uploads
# ./run.sh font-upload # Chunked font upload + variant creation
# ./run.sh concurrent-edit # Concurrent editing (same-file or multi-file)
# ./run.sh all # Run all scenarios together (orchestrator)
# ./run.sh clean # Remove test results
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
BASE_URL="${PENPOT_BASE_URL:-http://localhost:6060}"
VUS=""
ITER=""
DURATION=""
REGISTER_MODE="${PENPOT_REGISTER_MODE:-demo}"
K6="${K6:-k6}"
EDIT_MODE="${PENPOT_EDIT_MODE:-same-file}"
FILE_COUNT="${PENPOT_FILE_COUNT:-1}"
VUS_PER_FILE="${PENPOT_VUS_PER_FILE:-1}"
EDIT_ITERATIONS=""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Penpot Performance Tests
Usage:
$(basename "$0") <command> [options]
Commands:
smoke 1 VU, 1 iteration smoke test of the lifecycle flow
lifecycle Full user lifecycle (register → CRUD → delete)
workspace-open Read-heavy: repeatedly open a file (get-file, libraries, thumbnails)
workspace-edit Write-heavy: repeatedly edit a file (get-file + update-file loop)
media-upload Upload images of varying sizes (direct + chunked)
font-upload Upload fonts via chunked upload + create-font-variant
concurrent-edit Concurrent editing: same-file or multi-file mode
file-size-matrix Measure latency vs file size (10, 100, 500, 1000 shapes)
compare Compare two k6 JSON results for regression
all Run all scenarios together (orchestrator)
clean Remove test results
help Show this help
Options:
-u URL Backend base URL (default: $BASE_URL)
-v NUM Number of virtual users (default: per-script defaults)
-n NUM Iterations per VU (default: per-script defaults)
-d DURATION Test duration (e.g. 30s, 5m, 2h; default: k6 default)
-m MODE Register mode: 'demo' or 'register' (default: $REGISTER_MODE)
-k PATH Path to k6 binary (default: $K6)
Concurrent-edit options:
--mode MODE 'same-file' or 'multi-file' (default: $EDIT_MODE)
--files NUM Number of files for multi-file mode (default: $FILE_COUNT)
--vus-per-file NUM VUs per file for multi-file mode (default: $VUS_PER_FILE)
--edit-iterations NUM Per-VU edit loop iterations (concurrent-edit, file-size-matrix; default: 10)
Environment variables:
PENPOT_BASE_URL Same as -u
PENPOT_REGISTER_MODE Same as -m
PENPOT_EDIT_MODE Same as --mode
PENPOT_FILE_COUNT Same as --files
PENPOT_VUS_PER_FILE Same as --vus-per-file
PENPOT_EDIT_ITERATIONS Same as --edit-iterations
K6 Same as -k
PENPOT_DURATION Same as -d
Examples:
$(basename "$0") smoke
$(basename "$0") lifecycle -v 5 -n 10
$(basename "$0") workspace-edit -v 20 -n 50
$(basename "$0") media-upload -u https://penpot.example.com
$(basename "$0") concurrent-edit --mode same-file -v 5 -n 10
$(basename "$0") concurrent-edit --mode multi-file --files 3 --vus-per-file 2 -n 10
$(basename "$0") file-size-matrix -n 10
$(basename "$0") all -v 50
EOF
}
check_k6() {
if ! command -v "$K6" &>/dev/null; then
echo "Error: k6 not found at '$K6'" >&2
echo "Install from https://k6.io/docs/get-started/installation/" >&2
exit 1
fi
}
# Build k6 env flags
k6_env_flags() {
local flags="--env PENPOT_BASE_URL=$BASE_URL --env PENPOT_REGISTER_MODE=$REGISTER_MODE --env PENPOT_EDIT_MODE=$EDIT_MODE --env PENPOT_FILE_COUNT=$FILE_COUNT --env PENPOT_VUS_PER_FILE=$VUS_PER_FILE"
if [[ -n "${PENPOT_TOTAL_VUS:-}" ]]; then
flags="$flags --env PENPOT_TOTAL_VUS=$PENPOT_TOTAL_VUS"
fi
if [[ -n "$VUS" ]]; then
flags="$flags --env K6_VUS=$VUS"
fi
if [[ -n "$ITER" ]]; then
flags="$flags --env K6_ITERATIONS=$ITER"
fi
if [[ -n "$EDIT_ITERATIONS" ]]; then
flags="$flags --env PENPOT_EDIT_ITERATIONS=$EDIT_ITERATIONS"
fi
echo "$flags"
}
# Build k6 VU/iteration/duration flags (only if explicitly set)
k6_scale_flags() {
local flags=""
if [[ -n "$VUS" ]]; then
flags="$flags --vus $VUS"
fi
if [[ -n "$ITER" ]]; then
flags="$flags --iterations $ITER"
elif [[ -n "$VUS" && -z "$DURATION" ]]; then
# k6 requires iterations/duration/stages alongside --vus.
# When only -v is given, default iterations to VUs so
# iterations >= VUs (k6 constraint for shared-iterations).
flags="$flags --iterations $VUS"
fi
if [[ -n "$DURATION" ]]; then
flags="$flags --duration $DURATION"
fi
echo "$flags"
}
# Run a single k6 script
run_script() {
local script="$1"
local label="$2"
local results_dir="$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)-${label}"
mkdir -p "$results_dir"
echo ""
echo "=== $label ==="
echo " Script: scripts/${script}"
echo " Base URL: $BASE_URL"
echo " Register mode: $REGISTER_MODE"
[[ -n "$VUS" ]] && echo " VUs: $VUS"
[[ -n "$ITER" ]] && echo " Iterations: $ITER"
echo " Results: $results_dir"
echo ""
# shellcheck disable=SC2046
$K6 run \
$(k6_env_flags) \
$(k6_scale_flags) \
--out "json=$results_dir/k6-summary.json" \
"$SCRIPT_DIR/scripts/${script}"
}
# Run all scenarios as parallel k6 processes
run_all() {
local results_dir="$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)-all"
mkdir -p "$results_dir"
local default_vus="${VUS:-10}"
echo ""
echo "=== Penpot Performance Orchestrator ==="
echo " Base URL: $BASE_URL"
echo " Total VUs: $default_vus (distributed across flows)"
echo " Results: $results_dir"
echo ""
echo " Flow distribution:"
echo " lifecycle: 2 VUs (full CRUD)"
echo " workspace-open: 3 VUs (read-heavy)"
echo " workspace-edit: 3 VUs (write-heavy)"
echo " media-upload: 1 VU (storage I/O)"
echo " font-upload: 1 VU (CPU/storage)"
echo ""
local pids=()
# Lifecycle — full CRUD
$K6 run \
$(k6_env_flags) \
--vus 2 --iterations 2 \
--env "PENPOT_OPEN_ITERATIONS=3" \
--out "json=$results_dir/lifecycle.json" \
"$SCRIPT_DIR/scripts/lifecycle.js" &
pids+=($!)
# Workspace open — read-heavy
$K6 run \
$(k6_env_flags) \
--vus 3 --iterations 3 \
--env "PENPOT_OPEN_ITERATIONS=3" \
--out "json=$results_dir/workspace-open.json" \
"$SCRIPT_DIR/scripts/workspace-open.js" &
pids+=($!)
# Workspace edit — write-heavy
$K6 run \
$(k6_env_flags) \
--vus 3 --iterations 5 \
--env "PENPOT_EDIT_ITERATIONS=5" \
--out "json=$results_dir/workspace-edit.json" \
"$SCRIPT_DIR/scripts/workspace-edit.js" &
pids+=($!)
# Media upload
$K6 run \
$(k6_env_flags) \
--vus 1 --iterations 2 \
--out "json=$results_dir/media-upload.json" \
"$SCRIPT_DIR/scripts/media-upload.js" &
pids+=($!)
# Font upload
$K6 run \
$(k6_env_flags) \
--vus 1 --iterations 2 \
--out "json=$results_dir/font-upload.json" \
"$SCRIPT_DIR/scripts/font-upload.js" &
pids+=($!)
# Wait for all and collect exit codes
local failed=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
failed=$((failed + 1))
fi
done
echo ""
if [[ $failed -gt 0 ]]; then
echo "WARNING: $failed flow(s) had non-zero exit codes"
fi
echo "Results saved to: $results_dir"
}
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
cmd_smoke() {
check_k6
REGISTER_MODE=demo
VUS=1
ITER=1
run_script "lifecycle.js" "smoke"
}
cmd_lifecycle() { check_k6; run_script "lifecycle.js" "lifecycle"; }
cmd_workspace_open() { check_k6; run_script "workspace-open.js" "workspace-open"; }
cmd_workspace_edit() { check_k6; run_script "workspace-edit.js" "workspace-edit"; }
cmd_media_upload() { check_k6; run_script "media-upload.js" "media-upload"; }
cmd_font_upload() { check_k6; run_script "font-upload.js" "font-upload"; }
cmd_all() { check_k6; run_all; }
cmd_concurrent_edit() {
check_k6
local label="concurrent-edit-${EDIT_MODE}"
if [[ "$EDIT_MODE" == "multi-file" ]]; then
label="${label}-${FILE_COUNT}files-${VUS_PER_FILE}vpu"
fi
echo ""
echo "=== Concurrent Edit ($EDIT_MODE) ==="
echo " Mode: $EDIT_MODE"
if [[ "$EDIT_MODE" == "multi-file" ]]; then
echo " Files: $FILE_COUNT"
echo " VUs per file: $VUS_PER_FILE"
VUS=$((FILE_COUNT * VUS_PER_FILE))
echo " Total VUs: $VUS"
else
[[ -n "$VUS" ]] && echo " VUs: $VUS"
fi
[[ -n "$ITER" ]] && echo " Iterations: $ITER"
echo ""
# For same-file mode, pass VUS as PENPOT_TOTAL_VUS so setup() knows how many pages to create
if [[ "$EDIT_MODE" == "same-file" && -n "$VUS" ]]; then
export PENPOT_TOTAL_VUS="$VUS"
fi
run_script "workspace-edit-concurrent.js" "$label"
}
cmd_file_size_matrix() {
check_k6
echo ""
echo "=== File Size Matrix ==="
echo " Tiers: small(10), medium(100), large(500), xlarge(1000)"
[[ -n "$EDIT_ITERATIONS" ]] && echo " Iterations: $EDIT_ITERATIONS (per tier)"
echo ""
run_script "file-size-matrix.js" "file-size-matrix"
}
cmd_compare() {
local baseline="$1"
local current="$2"
local threshold="${3:-20}"
if [[ -z "$baseline" || -z "$current" ]]; then
echo "Usage: ./run.sh compare <baseline.json> <current.json> [threshold]"
echo ""
echo "Compare two k6 JSON results for performance regression."
echo ""
echo "Arguments:"
echo " baseline.json k6 JSON output from base branch"
echo " current.json k6 JSON output from PR branch"
echo " threshold Fail if p95 increases > N% (default: 20)"
exit 1
fi
if [[ ! -f "$baseline" ]]; then
echo "Error: Baseline file not found: $baseline" >&2
exit 1
fi
if [[ ! -f "$current" ]]; then
echo "Error: Current file not found: $current" >&2
exit 1
fi
node "$SCRIPT_DIR/scripts/compare-results.cjs" "$baseline" "$current" --threshold "$threshold"
}
cmd_clean() {
local results_dir="$SCRIPT_DIR/results"
if [[ -d "$results_dir" ]]; then
rm -rf "$results_dir"
echo "Cleaned $results_dir"
else
echo "Nothing to clean"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
# Parse global options first (before command)
parse_opts() {
# First, extract long options (--mode, --files, --vus-per-file)
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
EDIT_MODE="$2"
shift 2
;;
--files)
FILE_COUNT="$2"
shift 2
;;
--vus-per-file)
VUS_PER_FILE="$2"
shift 2
;;
--edit-iterations)
EDIT_ITERATIONS="$2"
shift 2
;;
*)
args+=("$1")
shift
;;
esac
done
# Apply PENPOT_DURATION env var as default (before CLI parsing takes precedence)
if [[ -z "$DURATION" && -n "${PENPOT_DURATION:-}" ]]; then
DURATION="$PENPOT_DURATION"
fi
# Now parse short options with getopts
set -- "${args[@]}"
OPTIND=1
while getopts "u:v:n:d:m:k:h" opt; do
case "$opt" in
u) BASE_URL="$OPTARG" ;;
v) VUS="$OPTARG" ;;
n) ITER="$OPTARG" ;;
d) DURATION="$OPTARG" ;;
m) REGISTER_MODE="$OPTARG" ;;
k) K6="$OPTARG" ;;
h) usage; exit 0 ;;
*) usage >&2; exit 1 ;;
esac
done
}
if [[ $# -lt 1 ]]; then
usage >&2
exit 1
fi
command="$1"
shift
# Parse options for flow commands (not smoke/clean/help/all)
case "$command" in
smoke|clean|help|-h|--help)
;;
*)
parse_opts "$@"
;;
esac
case "$command" in
smoke) cmd_smoke ;;
lifecycle) cmd_lifecycle ;;
workspace-open) cmd_workspace_open ;;
workspace-edit) cmd_workspace_edit ;;
media-upload) cmd_media_upload ;;
font-upload) cmd_font_upload ;;
concurrent-edit) cmd_concurrent_edit ;;
file-size-matrix) cmd_file_size_matrix ;;
compare) cmd_compare "$@" ;;
all) cmd_all ;;
clean) cmd_clean ;;
help|-h|--help) usage ;;
*)
echo "Unknown command: $command" >&2
usage >&2
exit 1
;;
esac

View File

@ -0,0 +1,270 @@
#!/usr/bin/env node
//
// compare-results.js
//
// Compares two k6 JSON output files and reports performance regressions.
// Used for relative comparison: base branch vs PR branch in the same CI run.
//
// Usage:
// node scripts/compare-results.js <baseline.json> <current.json>
// node scripts/compare-results.js <baseline.json> <current.json> --threshold 20
//
// Exit codes:
// 0 - No regressions detected
// 1 - Regression detected (p95 increased > threshold)
// 2 - Error (invalid input, missing file, etc.)
const fs = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const DEFAULT_THRESHOLD = 20; // Fail if p95 increases > 20%
const CRITICAL_COMMANDS = [
"get-file",
"update-file",
"login-with-password",
"create-demo-profile",
"get-file-libraries",
"get-file-object-thumbnails",
];
// ---------------------------------------------------------------------------
// Parse k6 JSON output
// ---------------------------------------------------------------------------
function parseK6Json(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.trim().split("\n");
// Collect all http_req_duration points with rpc_command tag
const durations = {}; // { rpc_command: [value, ...] }
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (
entry.type === "Point" &&
entry.metric === "http_req_duration" &&
entry.data?.tags?.rpc_command
) {
const cmd = entry.data.tags.rpc_command;
const value = entry.data.value;
if (!durations[cmd]) {
durations[cmd] = [];
}
durations[cmd].push(value);
}
} catch (e) {
// Skip malformed lines
}
}
return durations;
}
// ---------------------------------------------------------------------------
// Calculate percentiles
// ---------------------------------------------------------------------------
function percentile(values, p) {
if (values.length === 0) return 0;
const sorted = values.slice().sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
function calculateStats(values) {
if (values.length === 0) {
return { count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, avg: 0 };
}
const sorted = values.slice().sort((a, b) => a - b);
const sum = values.reduce((a, b) => a + b, 0);
return {
count: values.length,
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
min: sorted[0],
max: sorted[sorted.length - 1],
avg: sum / values.length,
};
}
// ---------------------------------------------------------------------------
// Compare two results
// ---------------------------------------------------------------------------
function compareResults(baseline, current, threshold) {
const results = [];
const allCommands = new Set([
...Object.keys(baseline),
...Object.keys(current),
]);
for (const cmd of allCommands) {
const baseStats = calculateStats(baseline[cmd] || []);
const currStats = calculateStats(current[cmd] || []);
// Calculate p95 change percentage
let p95Change = 0;
if (baseStats.p95 > 0) {
p95Change = ((currStats.p95 - baseStats.p95) / baseStats.p95) * 100;
} else if (currStats.p95 > 0) {
p95Change = 100; // New command with latency
}
const isCritical = CRITICAL_COMMANDS.includes(cmd);
const isRegression = p95Change > threshold;
results.push({
command: cmd,
isCritical,
baseline: baseStats,
current: currStats,
p95Change: Math.round(p95Change * 100) / 100,
isRegression,
});
}
// Sort: regressions first, then by p95 change descending
results.sort((a, b) => {
if (a.isRegression !== b.isRegression) return b.isRegression - a.isRegression;
return b.p95Change - a.p95Change;
});
return results;
}
// ---------------------------------------------------------------------------
// Print report
// ---------------------------------------------------------------------------
function printReport(results, threshold) {
console.log("\n=== Performance Regression Report ===\n");
console.log(`Threshold: p95 increase > ${threshold}%\n`);
// Print table header
const header = [
"Command".padEnd(30),
"Baseline p95".padStart(12),
"Current p95".padStart(12),
"Change".padStart(10),
"Status".padStart(10),
].join(" | ");
console.log(header);
console.log("-".repeat(header.length));
// Print results
for (const r of results) {
const baseP95 = `${Math.round(r.baseline.p95)}ms`;
const currP95 = `${Math.round(r.current.p95)}ms`;
const change = `${r.p95Change > 0 ? "+" : ""}${r.p95Change}%`;
const status = r.isRegression ? "FAIL" : "OK";
const critical = r.isCritical ? " *" : "";
const row = [
(r.command + critical).padEnd(30),
baseP95.padStart(12),
currP95.padStart(12),
change.padStart(10),
status.padStart(10),
].join(" | ");
console.log(row);
}
// Print legend
console.log("\n* = Critical command (always checked)");
// Print regressions summary
const regressions = results.filter((r) => r.isRegression);
if (regressions.length > 0) {
console.log(`\n❌ REGRESSION DETECTED: ${regressions.length} command(s) exceeded threshold`);
for (const r of regressions) {
console.log(` - ${r.command}: p95 ${Math.round(r.baseline.p95)}ms → ${Math.round(r.current.p95)}ms (+${r.p95Change}%)`);
}
} else {
console.log("\n✅ No regressions detected");
}
return regressions.length;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
// Parse arguments
let baselineFile = null;
let currentFile = null;
let threshold = DEFAULT_THRESHOLD;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--threshold" && args[i + 1]) {
threshold = parseInt(args[i + 1], 10);
i++;
} else if (!baselineFile) {
baselineFile = args[i];
} else if (!currentFile) {
currentFile = args[i];
}
}
// Validate arguments
if (!baselineFile || !currentFile) {
console.error("Usage: node compare-results.js <baseline.json> <current.json> [--threshold N]");
console.error("");
console.error("Arguments:");
console.error(" baseline.json k6 JSON output from base branch");
console.error(" current.json k6 JSON output from PR branch");
console.error(" --threshold N Fail if p95 increases > N% (default: 20)");
process.exit(2);
}
// Check files exist
if (!fs.existsSync(baselineFile)) {
console.error(`Error: Baseline file not found: ${baselineFile}`);
process.exit(2);
}
if (!fs.existsSync(currentFile)) {
console.error(`Error: Current file not found: ${currentFile}`);
process.exit(2);
}
// Parse files
console.log(`Parsing baseline: ${path.basename(baselineFile)}`);
const baseline = parseK6Json(baselineFile);
const baseCommands = Object.keys(baseline).length;
console.log(` Found ${baseCommands} RPC commands`);
console.log(`Parsing current: ${path.basename(currentFile)}`);
const current = parseK6Json(currentFile);
const currCommands = Object.keys(current).length;
console.log(` Found ${currCommands} RPC commands`);
if (baseCommands === 0 && currCommands === 0) {
console.error("Error: No RPC command data found in either file");
process.exit(2);
}
// Compare and report
const results = compareResults(baseline, current, threshold);
const regressionCount = printReport(results, threshold);
// Exit with appropriate code
process.exit(regressionCount > 0 ? 1 : 0);
}
main();

View File

@ -0,0 +1,249 @@
// File Size Matrix Performance Test
//
// Measures how update-file and get-file latency scales with file size.
// Creates files with different shape counts (10, 100, 500, 1000) and
// benchmarks operations on each.
//
// Usage:
// k6 run scripts/file-size-matrix.js
// k6 run --iterations 10 scripts/file-size-matrix.js
// ./run.sh file-size-matrix
// ./run.sh file-size-matrix -n 10
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const ITERATIONS_PER_TIER = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "5");
// Shape tiers
const TIERS = [
{ name: "small", shapes: 10, color: "#ff0000" },
{ name: "medium", shapes: 100, color: "#00ff00" },
{ name: "large", shapes: 500, color: "#0000ff" },
{ name: "xlarge", shapes: 1000, color: "#ff00ff" },
];
export const options = {
thresholds: {
http_req_duration: ["p(95)<10000"],
http_req_failed: ["rate<0.01"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index, color) {
const shapeId = uuidv4();
const x = 50 + (index % 20) * 30;
const y = 50 + Math.floor(index / 20) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: color, fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// Populate a file with N shapes in a single update-file call
function populateFile(client, fileId, pageId, shapeCount, color) {
// Get current file state
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) return null;
let { revn, vern } = getFileRes.body;
// Add shapes in batches (backend may have limits on changes per call)
const BATCH_SIZE = 100;
let added = 0;
while (added < shapeCount) {
const batchCount = Math.min(BATCH_SIZE, shapeCount - added);
const changes = [];
for (let i = 0; i < batchCount; i++) {
changes.push(makeAddRectChange(pageId, added + i, color));
}
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
console.error(`Failed to add batch at ${added}: ${JSON.stringify(updateRes.body)}`);
return null;
}
// update-file returns {revn, lagged} but not vern
// vern only changes on snapshot restore, so keep the original
revn = updateRes.body.revn;
added += batchCount;
}
return { revn, vern };
}
// ---------------------------------------------------------------------------
// Setup — create files with different shape counts
// ---------------------------------------------------------------------------
export function setup() {
console.log(`File Size Matrix Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Iterations/tier: ${ITERATIONS_PER_TIER}`);
console.log(` Tiers: ${TIERS.map(t => `${t.name}(${t.shapes})`).join(", ")}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create demo profile
const userRes = client.rpc("POST", "create-demo-profile", {});
if (userRes.status !== 200) fail("Failed to create demo profile");
const user = userRes.json();
console.log(` Created demo profile: ${user.email}`);
// Login
if (client.login(user.email, user.password).status !== 200) fail("Login failed");
const teamId = client.getTeams().body[0].id;
const projectId = client.createProject(teamId, "File Size Matrix Project").body.id;
console.log(` Project: ${projectId}`);
// Create and populate files for each tier
const tiers = [];
for (const tier of TIERS) {
console.log(`\n Creating ${tier.name} file (${tier.shapes} shapes)...`);
// Create file
const fileRes = client.createFile(projectId, `Matrix ${tier.name} (${tier.shapes} shapes)`);
if (fileRes.status !== 200) fail(`Failed to create ${tier.name} file`);
const fileId = fileRes.body.id;
// Get page ID
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail(`Failed to get ${tier.name} file`);
const pageId = getFileRes.body.data.pages[0];
// Populate with shapes
const startTime = Date.now();
const result = populateFile(client, fileId, pageId, tier.shapes, tier.color);
if (!result) fail(`Failed to populate ${tier.name} file`);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(` ${tier.name}: ${tier.shapes} shapes in ${elapsed}s (revn=${result.revn})`);
tiers.push({
name: tier.name,
shapes: tier.shapes,
fileId,
pageId,
revn: result.revn,
vern: result.vern,
});
}
console.log(`\n Setup complete. ${tiers.length} files ready.`);
return { baseUrl: BASE_URL, user, tiers, iterationsPerTier: ITERATIONS_PER_TIER };
}
// ---------------------------------------------------------------------------
// Main VU Function — benchmark each tier
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Login
if (!assertOk(client.login(data.user.email, data.user.password), "login")) fail("login failed");
sleep(0.5);
console.log(`\n=== Starting benchmark (${data.iterationsPerTier} iterations per tier) ===\n`);
// Benchmark each tier
for (const tier of data.tiers) {
console.log(`--- Tier: ${tier.name} (${tier.shapes} shapes) ---`);
// Get latest file state
const getFileRes = client.getFile(tier.fileId);
if (!assertOk(getFileRes, `get-file-${tier.name}`)) continue;
let { revn, vern } = getFileRes.body;
for (let i = 0; i < data.iterationsPerTier; i++) {
// Benchmark get-file
const getRes = client.getFile(tier.fileId);
if (!assertOk(getRes, `get-file-${tier.name}`)) continue;
sleep(0.2);
// Benchmark update-file (add 1 shape)
const change = makeAddRectChange(tier.pageId, tier.shapes + i, "#ffaa00");
const updateRes = client.updateFile(tier.fileId, getRes.body.revn, getRes.body.vern, client.sessionId, [change]);
if (updateRes.status !== 200) {
console.error(`update-file failed on ${tier.name} iteration ${i}: ${JSON.stringify(updateRes.body)}`);
continue;
}
// update-file returns {revn, lagged} but not vern
// vern only changes on snapshot restore, so keep the original
revn = updateRes.body.revn;
sleep(0.3);
}
console.log(` Completed ${data.iterationsPerTier} iterations on ${tier.name}`);
}
console.log(`\n=== Benchmark complete ===`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log(`File size matrix test complete.`);
}

View File

@ -0,0 +1,158 @@
// Font Upload Performance Test
//
// Tests the font upload flow: chunked upload of TTF + OTF files followed by
// creating a font variant. Exercises storage pipeline and font processing.
//
// setup() creates N demo profiles.
// Each VU picks its user, uploads fonts, and creates a variant.
//
// Usage:
// k6 run scripts/font-upload.js
// k6 run --vus 50 --iterations 5 scripts/font-upload.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
export const options = {
thresholds: {
http_req_duration: ["p(95)<15000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:create-upload-session}": ["p(95)<1000"],
"http_req_duration{rpc_command:upload-chunk}": ["p(95)<5000"],
"http_req_duration{rpc_command:create-font-variant}": ["p(95)<10000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const fontTtf = open("../../test/backend_tests/test_files/font-1.ttf", "b");
const fontOtf = open("../../test/backend_tests/test_files/font-1.otf", "b");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Font Upload Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
const teamId = client.getTeams().body[0].id;
sleep(0.5);
const fontId = uuidv4();
const fontFamily = `PerfFont-${uuidv4().substring(0, 8)}`;
const chunkSize = 50 * 1024; // 50 KB
// Upload TTF via chunked upload
const ttfChunks = Math.ceil(fontTtf.byteLength / chunkSize);
const ttfSessionRes = client.createUploadSession(ttfChunks);
if (!assertOk(ttfSessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed");
const ttfSessionId = ttfSessionRes.sessionId;
for (let i = 0; i < ttfChunks; i++) {
const chunk = fontTtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontTtf.byteLength));
if (!assertOk(client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"), `upload-chunk ttf ${i}`)) fail("ttf chunk failed");
sleep(0.1);
}
// Upload OTF via chunked upload
const otfChunks = Math.ceil(fontOtf.byteLength / chunkSize);
const otfSessionRes = client.createUploadSession(otfChunks);
if (!assertOk(otfSessionRes, "create-upload-session (otf)")) fail("create-upload-session (otf) failed");
const otfSessionId = otfSessionRes.sessionId;
for (let i = 0; i < otfChunks; i++) {
const chunk = fontOtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontOtf.byteLength));
if (!assertOk(client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"), `upload-chunk otf ${i}`)) fail("otf chunk failed");
sleep(0.1);
}
sleep(0.5);
// Create font variant
if (!assertOk(client.rpc("POST", "create-font-variant", {
"team-id": teamId,
"font-id": fontId,
"font-family": fontFamily,
"font-weight": 400,
"font-style": "normal",
uploads: { "font/ttf": ttfSessionId, "font/otf": otfSessionId },
}), "create-font-variant")) fail("create-font-variant failed");
console.log(`VU ${__VU}: Font "${fontFamily}" created`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Font upload test complete.");
}

View File

@ -0,0 +1,260 @@
// Lifecycle Performance Test
//
// Simulates a realistic user lifecycle from registration through CRUD operations.
// Each VU performs the full flow independently, creating its own artifacts.
//
// setup() creates a user pool (one demo profile per VU) before measurements begin.
// Each VU picks its assigned user to login — no profile creation during the test.
//
// Flow:
// 1. Login (with pre-existing user from pool)
// 2. Get profile & teams
// 3. Create project
// 4. Create file
// 5. Get file
// 6. Update file (add a shape)
// 7. Upload images (direct + chunked)
// 8. Delete file
// 9. Delete project
// 10. Logout
//
// Usage:
// k6 run scripts/lifecycle.js
// k6 run --vus 100 --iterations 100 scripts/lifecycle.js
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/lifecycle.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
// k6 options — VUs and iterations set via run.sh (--vus, --iterations)
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:login-with-password}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-profile}": ["p(95)<500"],
"http_req_duration{rpc_command:create-project}": ["p(95)<1000"],
"http_req_duration{rpc_command:create-file}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<2000"],
"http_req_duration{rpc_command:delete-file}": ["p(95)<1000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const testImageSmall = open("../../test/backend_tests/test_files/sample.png", "b");
const testImageLarge = open("../../test/backend_tests/test_files/sample.jpg", "b");
// A minimal "add-obj" change payload for update-file.
function makeAddRectChange(pageId) {
const shapeId = uuidv4();
const x = 100;
const y = 100;
const w = 200;
const h = 150;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId,
type: "rect",
name: "Perf Test Rect",
x: x, y: y, width: w, height: h,
fillColor: "#ff0000", fillOpacity: 1,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool before VUs start
// ---------------------------------------------------------------------------
export function setup() {
// Resolve VU count from options or CLI --vus flag
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Lifecycle Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(` Creating ${vuCount} demo profiles...`);
console.log(``);
const client = createClient(BASE_URL);
// Verify backend is reachable
const pingRes = client.getProfile();
if (pingRes.status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create one demo profile per VU
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) {
fail(`Failed to create demo profile ${i + 1}/${vuCount}: status=${res.status}`);
}
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) {
fail(`No user for VU ${__VU} (pool size: ${data.users.length})`);
}
// ---- Step 1: Login ----
const loginRes = client.login(user.email, user.password);
if (!assertOk(loginRes, "login-with-password")) fail("Login failed");
const profile = loginRes.body;
const profileId = profile.id;
sleep(1);
// ---- Step 2: Get profile ----
if (!assertOk(client.getProfile(), "get-profile")) fail("get-profile failed");
sleep(0.5);
// ---- Step 3: Get teams ----
const teamsRes = client.getTeams();
if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed");
const defaultTeamId = teamsRes.body[0].id;
sleep(0.5);
// ---- Step 4: Create a project ----
const projectRes = client.createProject(defaultTeamId, `Perf Project ${uuidv4().substring(0, 8)}`);
if (!assertOk(projectRes, "create-project")) fail("create-project failed");
const projectId = projectRes.body.id;
sleep(1);
// ---- Step 5: Create a file ----
const fileRes = client.createFile(projectId, `Perf File ${uuidv4().substring(0, 8)}`);
if (!assertOk(fileRes, "create-file")) fail("create-file failed");
const fileId = fileRes.body.id;
sleep(1);
// ---- Step 6: Get the file ----
const getFileRes = client.getFile(fileId);
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
const fileData = getFileRes.body;
const pageId = fileData.data.pages[0];
sleep(1);
// ---- Step 7: Update file (add a shape) ----
if (pageId) {
const changes = [makeAddRectChange(pageId)];
const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes);
if (updateRes.status !== 200) {
// Retry once on revn conflict
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
}
}
}
sleep(1);
// ---- Step 8: Upload images ----
if (testImageSmall && testImageSmall.byteLength > 0) {
assertOk(
client.uploadFileMediaObject(fileId, testImageSmall, "sample.png", "image/png"),
"upload (direct)"
);
}
sleep(0.5);
if (testImageLarge && testImageLarge.byteLength > 0) {
assertOk(
client.uploadFileMediaObject(fileId, testImageLarge, "sample.jpg", "image/jpeg"),
"upload (chunked)"
);
}
sleep(1);
// ---- Step 9: Delete file ----
assertOk(client.deleteFile(fileId), "delete-file");
sleep(0.5);
// ---- Step 10: Delete project ----
assertOk(client.deleteProject(projectId), "delete-project");
sleep(0.5);
// ---- Step 11: Logout ----
client.logout(profileId);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Lifecycle test complete.");
}

View File

@ -0,0 +1,142 @@
// Media Upload Performance Test
//
// Tests direct and chunked image uploads with varying file sizes.
// Each VU creates its own file and uploads multiple images to it.
//
// Upload sizes:
// - SVG (3.6 KB) → direct upload
// - PNG (5.1 KB) → direct upload
// - JPG (305 KB) → chunked upload (7 chunks at 50 KB each)
//
// Usage:
// k6 run scripts/media-upload.js
// k6 run --vus 50 --iterations 5 scripts/media-upload.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
export const options = {
thresholds: {
http_req_duration: ["p(95)<10000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:upload-file-media-object}": ["p(95)<5000"],
"http_req_duration{rpc_command:upload-chunk}": ["p(95)<5000"],
"http_req_duration{rpc_command:assemble-file-media-object}": ["p(95)<5000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const imageSvg = open("../../test/backend_tests/test_files/sample1.svg", "b");
const imagePng = open("../../test/backend_tests/test_files/sample.png", "b");
const imageJpg = open("../../test/backend_tests/test_files/sample.jpg", "b");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Media Upload Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
sleep(0.5);
// Get team
const teamId = client.getTeams().body[0].id;
// Create project + file
const projectId = client.createProject(teamId, `Media ${uuidv4().substring(0, 8)}`).body.id;
const fileId = client.createFile(projectId, `Media ${uuidv4().substring(0, 8)}`).body.id;
sleep(0.5);
// Upload SVG (direct — 3.6 KB)
assertOk(client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"), "upload SVG");
sleep(0.5);
// Upload PNG (direct — 5.1 KB)
assertOk(client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"), "upload PNG");
sleep(0.5);
// Upload JPG (chunked — 305 KB > 50 KB threshold)
assertOk(client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"), "upload JPG");
console.log(`VU ${__VU}: Media upload complete`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Media upload test complete.");
}

View File

@ -0,0 +1,361 @@
// Workspace Edit Concurrent Performance Test
//
// Two modes for measuring concurrent file editing:
//
// Mode 1: same-file — N VUs edit different pages in 1 file
// Measures lock contention on a single popular file.
// Bottleneck: advisory lock serialization (db/xact-lock!).
//
// Mode 2: multi-file — G groups × M VUs per file
// Each group edits its own file on its own page.
// Measures whole system responsiveness under parallel edit sessions.
// Bottleneck: DB connection pool, CPU, memory.
//
// Key insight: revn conflicts only occur when incoming > stored (should
// never happen in normal usage). The real contention point is the file-level
// advisory lock that serializes all update-file calls on the same file.
//
// Usage:
// # Same-file mode (default): 5 VUs edit different pages in 1 file
// k6 run --vus 5 --iterations 10 scripts/workspace-edit-concurrent.js
//
// # Multi-file mode: 3 files × 2 VUs each = 6 VUs total
// PENPOT_EDIT_MODE=multi-file PENPOT_FILE_COUNT=3 PENPOT_VUS_PER_FILE=2 \
// k6 run --vus 6 --iterations 10 scripts/workspace-edit-concurrent.js
//
// # Via run.sh
// ./run.sh concurrent-edit --mode same-file --vus 5 --iterations 10
// ./run.sh concurrent-edit --mode multi-file --files 3 --vus-per-file 2 --iterations 10
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const EDIT_MODE = __ENV.PENPOT_EDIT_MODE || "same-file"; // "same-file" or "multi-file"
const FILE_COUNT = parseInt(__ENV.PENPOT_FILE_COUNT || "1");
const VUS_PER_FILE = parseInt(__ENV.PENPOT_VUS_PER_FILE || "1");
const EDIT_ITERATIONS = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "50");
// Calculate total VUs based on mode
const TOTAL_VUS = EDIT_MODE === "multi-file"
? FILE_COUNT * VUS_PER_FILE
: parseInt(__ENV.PENPOT_TOTAL_VUS || "3");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<3000"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index) {
const shapeId = uuidv4();
const x = 50 + (index % 10) * 30;
const y = 50 + Math.floor(index / 10) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: "#00ff00", fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// Add a page to a file via update-file with add-page change
function addPage(client, fileId, revn, vern, pageId, pageName) {
const change = {
type: "add-page",
id: pageId,
name: pageName,
};
return client.updateFile(fileId, revn, vern, client.sessionId, [change]);
}
// ---------------------------------------------------------------------------
// Setup — create users, files, and pages based on mode
// ---------------------------------------------------------------------------
export function setup() {
console.log(`Penpot Concurrent Edit Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Mode: ${EDIT_MODE}`);
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
if (EDIT_MODE === "same-file") {
console.log(` Total VUs: ${TOTAL_VUS} (same file)`);
} else {
console.log(` Files: ${FILE_COUNT}`);
console.log(` VUs per file: ${VUS_PER_FILE}`);
console.log(` Total VUs: ${TOTAL_VUS}`);
}
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create demo profiles (one per VU)
const users = [];
for (let i = 0; i < TOTAL_VUS; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${TOTAL_VUS}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
// Login with first user to create shared team and files
const loginRes = client.login(users[0].email, users[0].password);
if (loginRes.status !== 200) fail("Login failed for setup");
// Create a shared team so all VUs can access the same file.
// Each demo profile gets its own default team; without a shared
// team, VUs 2+ would get 404 on get-file.
const teamRes = client.createTeam("Concurrent Edit Team");
if (teamRes.status !== 200) fail("Failed to create shared team");
const sharedTeamId = teamRes.body.id;
console.log(` Shared team: ${sharedTeamId}`);
// Invite remaining users to the shared team and get acceptance tokens.
// The tokens are used by each VU via verify-token to join the team.
const invitationTokens = [];
for (let i = 1; i < TOTAL_VUS; i++) {
const invRes = client.inviteTeamMembers(sharedTeamId, [users[i].email], "editor");
if (invRes.status !== 200) {
console.error(` Invite user ${i}: status=${invRes.status}`);
}
const tokenRes = client.getTeamInvitationToken(sharedTeamId, users[i].email);
if (tokenRes.status === 200 && tokenRes.body) {
invitationTokens.push({ vuIndex: i, token: tokenRes.body });
}
}
if (invitationTokens.length > 0) {
console.log(` Got ${invitationTokens.length} invitation tokens`);
} else {
console.log(` All users auto-added (no tokens needed)`);
}
// Create project and files in the shared team
const projectId = client.createProject(sharedTeamId, "Concurrent Edit Project").body.id;
console.log(` Project: ${projectId}`);
// Build file/page assignments based on mode
const fileAssignments = []; // [{ fileId, pageIds[] }]
const vuAssignments = []; // [{ vuIndex, fileId, pageId }]
if (EDIT_MODE === "same-file") {
// One file, N pages (one per VU)
const fileRes = client.createFile(projectId, "Shared Edit File");
if (fileRes.status !== 200) fail("Failed to create shared file");
const fileId = fileRes.body.id;
console.log(` Created file: ${fileId}`);
// Get initial file state (has 1 default page)
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail("Failed to get initial file");
const defaultPageId = getFileRes.body.data.pages[0];
let revn = getFileRes.body.revn;
let vern = getFileRes.body.vern;
// First VU uses the default page
const pageIds = [defaultPageId];
// Add remaining pages
// vern never changes on regular edits (only on snapshot restore),
// and each add-page increments revn by 1, so no need to re-fetch.
for (let i = 1; i < TOTAL_VUS; i++) {
const pageId = uuidv4();
const pageName = `Page ${i + 1}`;
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
if (addRes.status !== 200) fail(`Failed to add page ${i + 1}`);
revn++;
pageIds.push(pageId);
}
console.log(` Added ${pageIds.length} pages to file`);
fileAssignments.push({ fileId, pageIds });
// Each VU gets its own page in the same file
for (let i = 0; i < TOTAL_VUS; i++) {
vuAssignments.push({ vuIndex: i, fileId, pageId: pageIds[i] });
}
} else {
// Multi-file mode: G files, each with M pages
for (let f = 0; f < FILE_COUNT; f++) {
const fileRes = client.createFile(projectId, `Edit File ${f + 1}`);
if (fileRes.status !== 200) fail(`Failed to create file ${f + 1}`);
const fileId = fileRes.body.id;
console.log(` Created file ${f + 1}: ${fileId}`);
// Get initial file state (has 1 default page)
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail(`Failed to get file ${f + 1}`);
const defaultPageId = getFileRes.body.data.pages[0];
let revn = getFileRes.body.revn;
let vern = getFileRes.body.vern;
// First VU of this file uses the default page
const pageIds = [defaultPageId];
// Add remaining pages for this file
for (let p = 1; p < VUS_PER_FILE; p++) {
const pageId = uuidv4();
const pageName = `Page ${p + 1}`;
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
if (addRes.status !== 200) fail(`Failed to add page ${p + 1} to file ${f + 1}`);
revn++;
pageIds.push(pageId);
}
console.log(` Added ${pageIds.length} pages to file ${f + 1}`);
fileAssignments.push({ fileId, pageIds });
// Assign VUs to this file's pages
for (let p = 0; p < VUS_PER_FILE; p++) {
const vuIndex = f * VUS_PER_FILE + p;
vuAssignments.push({ vuIndex, fileId, pageId: pageIds[p] });
}
}
}
console.log(` Setup complete. ${vuAssignments.length} VU assignments.`);
console.log(``);
return {
baseUrl: BASE_URL,
editMode: EDIT_MODE,
users,
vuAssignments,
invitationTokens,
};
}
// ---------------------------------------------------------------------------
// Main VU Function — each VU edits its assigned page
// ---------------------------------------------------------------------------
// Track which VUs have accepted their invitation (once per VU, not per iteration)
const verifiedVus = {};
// ---------------------------------------------------------------------------
// Main VU Function — each VU edits its assigned page
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Each VU uses its own demo profile (different users editing the same file)
const vuIndex = __VU - 1;
const user = data.users[vuIndex];
const assignment = data.vuAssignments[vuIndex];
if (!user) fail(`No user for VU ${__VU} (index ${vuIndex})`);
if (!assignment) fail(`No assignment for VU ${__VU} (index ${vuIndex})`);
const { fileId, pageId } = assignment;
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
// Accept team invitation once per VU (not per iteration).
// In devenv the user may already be auto-added; 400 on already-accepted
// tokens is harmless — skip the token on subsequent iterations.
if (!verifiedVus[__VU]) {
const tokenEntry = data.invitationTokens.find((t) => t.vuIndex === vuIndex);
if (tokenEntry && tokenEntry.token) {
client.rpc("POST", "verify-token", tokenEntry.token);
}
verifiedVus[__VU] = true;
}
sleep(0.5);
// Edit loop
for (let i = 0; i < EDIT_ITERATIONS; i++) {
// Refresh file state to get latest revn
const refreshRes = client.getFile(fileId);
if (!assertOk(refreshRes, "get-file")) continue;
const { revn, vern } = refreshRes.body;
sleep(0.3);
// Submit a change to our assigned page
const changes = [makeAddRectChange(pageId, i)];
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
// This shouldn't happen in normal circumstances, but handle it gracefully
console.warn(`VU ${__VU}: revn conflict on iteration ${i} (unexpected)`);
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
} else {
console.error(`VU ${__VU}: update-file failed on iteration ${i}: ${JSON.stringify(body)}`);
}
}
sleep(1);
}
console.log(`VU ${__VU}: Completed ${EDIT_ITERATIONS} edits on file ${fileId}, page ${pageId}`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log(`Concurrent edit test complete (${data.editMode}).`);
}

View File

@ -0,0 +1,195 @@
// Workspace Edit Performance Test (Write-heavy)
//
// Simulates users editing files — repeatedly fetching the file (to get
// the latest revn) and submitting changes. Each VU edits its own file
// in its own project independently, so there are no concurrency conflicts.
//
// setup() creates N demo profiles + per-user project.
// Each VU picks its user, creates its own file, and edits it in a loop.
//
// Flow (per VU):
// Login → create file → loop: get-file → update-file → sleep
//
// Usage:
// k6 run scripts/workspace-edit.js
// k6 run --vus 100 --iterations 50 scripts/workspace-edit.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const EDIT_ITERATIONS = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "50");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<2000"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index) {
const shapeId = uuidv4();
const x = 50 + (index % 10) * 30;
const y = 50 + Math.floor(index / 10) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: "#00ff00", fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// ---------------------------------------------------------------------------
// Setup — create N users, each with their own project
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Workspace Edit Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create N demo profiles + per-user project.
// Each user needs their own project because demo profiles
// belong to different teams and cannot share a project.
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
const user = res.json();
// Login as this user and create their own project
const loginRes = client.login(user.email, user.password);
if (loginRes.status !== 200) fail(`Login failed for user ${i + 1}`);
const teamId = client.getTeams().body[0].id;
user.projectId = client.createProject(teamId, `WS Edit Project`).body.id;
users.push(user);
}
console.log(` Created ${users.length} demo profiles + projects`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function — each VU creates its own file and edits it
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
sleep(0.5);
// Create a file for this VU in their own project
const fileRes = client.createFile(user.projectId, `Edit File VU${__VU}`);
if (!assertOk(fileRes, "create-file")) fail("create-file failed");
const fileId = fileRes.body.id;
// Get initial file state
const getFileRes = client.getFile(fileId);
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
const pageId = getFileRes.body.data.pages[0];
sleep(0.5);
// Edit loop
for (let i = 0; i < EDIT_ITERATIONS; i++) {
// Refresh file state to get latest revn
const refreshRes = client.getFile(fileId);
if (!assertOk(refreshRes, "get-file")) continue;
const { revn, vern } = refreshRes.body;
sleep(0.3);
// Submit a change
const changes = [makeAddRectChange(pageId, i)];
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
// Retry once on revn conflict
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
}
}
sleep(1);
}
console.log(`VU ${__VU}: Completed ${EDIT_ITERATIONS} edits on file ${fileId}`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Workspace edit test complete.");
}

View File

@ -0,0 +1,157 @@
// Workspace Open Performance Test (Read-heavy)
//
// Simulates many users opening the same file in the workspace editor.
// This is the most common read-heavy operation — loading a file and its
// dependencies (libraries, thumbnails).
//
// setup() creates one user, one project, and one file with a shape.
// All VUs login with the same user and read the same file concurrently.
//
// Flow (per VU iteration):
// Login → get-file → get-file-libraries → get-file-object-thumbnails
// → get-file-data-for-thumbnail
//
// Usage:
// k6 run scripts/workspace-open.js
// k6 run --vus 100 --iterations 20 scripts/workspace-open.js
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/workspace-open.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const OPEN_ITERATIONS = parseInt(__ENV.PENPOT_OPEN_ITERATIONS || "5");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-libraries}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-object-thumbnails}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-data-for-thumbnail}": ["p(95)<500"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create one user + one file with data
// ---------------------------------------------------------------------------
export function setup() {
console.log(`Penpot Workspace Open Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Open iterations: ${OPEN_ITERATIONS}`);
console.log(``);
const client = createClient(BASE_URL);
// Verify backend reachable
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create one demo user
const demoRes = client.rpc("POST", "create-demo-profile", {});
if (demoRes.status !== 200) fail("Failed to create demo profile");
const { email, password } = demoRes.json();
// Login
const loginRes = client.login(email, password);
if (loginRes.status !== 200) fail("Login failed");
// Create project + file
const teamId = client.getTeams().body[0].id;
const projectId = client.createProject(teamId, "WS Open Project").body.id;
const fileId = client.createFile(projectId, "WS Open File").body.id;
// Get file data and add a shape so it has meaningful content
const fileData = client.getFile(fileId).body;
const pageId = fileData.data.pages[0];
const shapeId = uuidv4();
const x = 50, y = 50, w = 300, h = 200;
client.updateFile(fileId, fileData.revn, fileData.vern, uuidv4(), [{
type: "add-obj", pageId, id: shapeId, frameId: pageId, parentId: pageId,
obj: {
id: shapeId, type: "rect", name: "Background",
x, y, width: w, height: h,
fillColor: "#cccccc", fillOpacity: 1,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
}]);
console.log(` File ready: ${fileId} (page: ${pageId})`);
return { baseUrl: BASE_URL, email, password, fileId };
}
// ---------------------------------------------------------------------------
// Main VU Function — all VUs read the same file
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Login with shared user
if (!assertOk(client.login(data.email, data.password), "login")) fail("login failed");
sleep(0.5);
for (let i = 0; i < OPEN_ITERATIONS; i++) {
if (!assertOk(client.getFile(data.fileId), "get-file")) fail("get-file failed");
sleep(0.3);
if (!assertOk(client.getFileLibraries(data.fileId), "get-file-libraries")) fail("get-file-libraries failed");
sleep(0.2);
if (!assertOk(client.getFileObjectThumbnails(data.fileId), "get-file-object-thumbnails")) fail("get-file-object-thumbnails failed");
sleep(0.2);
if (!assertOk(client.getFileDataForThumbnail(data.fileId), "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed");
sleep(1);
}
console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} open iterations`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Workspace open test complete.");
}

View File

@ -14,10 +14,21 @@
:iterations 3
:parallelism 2})
(def ^:private weak-options
{:alg :pbkdf2+sha256
:iterations 100})
(defn derive-password
[password]
(hashers/derive password default-options))
(defn derive-password-weak
"Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations).
Intended for demo users only they are already gated behind the
`demo-users` config flag which is disabled in production."
[password]
(hashers/derive password weak-options))
(defn verify-password
[attempt password]
(try

View File

@ -391,6 +391,8 @@
:delete-object
(ig/ref :app.tasks.delete-object/handler)
:demo-purge
(ig/ref :app.tasks.demo-purge/handler)
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
@ -428,6 +430,9 @@
:app.tasks.delete-object/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.demo-purge/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}

View File

@ -7,9 +7,9 @@
(ns app.rpc.commands.demo
"A demo specific mutations."
(:require
[app.auth :refer [derive-password]]
[app.auth :refer [derive-password-weak]]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
@ -17,6 +17,7 @@
[app.rpc.commands.auth :as auth]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]
[app.worker :as wrk]
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]))
@ -34,8 +35,8 @@
:code :demo-users-not-allowed
:hint "Demo users are disabled by config."))
(let [sem (System/currentTimeMillis)
email (str "demo-" sem ".demo@example.com")
(let [sem (uuid/next)
email (str "demo-" sem "@demo.example.com")
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
@ -46,12 +47,18 @@
:fullname fullname
:is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:password (derive-password-weak password)
:props {}}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
(wrk/submit! (-> cfg
(assoc ::wrk/task :demo-purge)
(assoc ::wrk/delay (cf/get-deletion-delay))
(assoc ::wrk/params {:profile-id (:id profile)})))
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))

View File

@ -124,9 +124,7 @@
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(-> (db/get-by-id conn :profile id opts)
(decode-row)))
;; --- MUTATION: Update Profile (own)

View File

@ -0,0 +1,41 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
creation time with a delay matching the configured deletion-delay."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[app.worker :as wrk]
[integrant.core :as ig]))
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props]}]
(let [profile-id (get props :profile-id)
now (ct/now)]
(l/trc :hint "demo-purge" :profile-id (str profile-id))
;; Mark the profile for immediate deletion
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(db/update! conn :profile
{:deleted-at now}
{:id profile-id}
{::db/return-keys false})
(wrk/submit!
(-> cfg
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :profile
:deleted-at now
:id profile-id}))))))))

View File

@ -0,0 +1,47 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.demo-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.rpc.commands.profile :as profile]
[app.tasks.demo-purge :as demo-purge]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.test :as t]
[integrant.core :as ig]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest demo-profile-created-without-deleted-at
(let [profile (th/create-profile* 999 {:is-demo true})]
(t/is (true? (:is-demo profile)))
(t/is (nil? (:deleted-at profile)))
(t/is (some? (:id profile)))))
(t/deftest get-profile-finds-demo-user-without-override
(let [profile (th/create-profile* 998 {:is-demo true})
found (db/run! th/*pool*
(fn [{:keys [::db/conn]}]
(profile/get-profile conn (:id profile))))]
(t/is (some? found))
(t/is (= (:id profile) (:id found)))))
(t/deftest demo-purge-handler-submits-delete-object
(let [profile (th/create-profile* 996 {:is-demo true})
handler (ig/init-key :app.tasks.demo-purge/handler
{::db/pool th/*pool*})
submitted (atom nil)]
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}]
(reset! submitted {:task task :params params}))]
(handler {:props {:profile-id (:id profile)
:deleted-at (ct/now)}}))
(t/is (= :delete-object (:task @submitted)))
(t/is (= :profile (:object (:params @submitted))))
(t/is (= (:id profile) (:id (:params @submitted))))
(t/is (some? (:deleted-at (:params @submitted))))))

View File

@ -186,6 +186,7 @@ ENV CLJKONDO_VERSION=2026.04.15 \
CLJFMT_VERSION=0.16.4 \
PIXI_VERSION=0.67.2 \
GITHUB_CLI_VERSION=2.91.0 \
K6_VERSION=2.0.0 \
UV_VERSION=0.11.9 \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/opt/utils/bin \
@ -314,6 +315,28 @@ RUN set -ex; \
mv /tmp/mc /opt/utils/bin/; \
chmod +x /opt/utils/bin/mc;
# Install k6
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/grafana/k6/releases/download/v$K6_VERSION/k6-v$K6_VERSION-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/grafana/k6/releases/download/v$K6_VERSION/k6-v$K6_VERSION-linux-amd64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/k6.tar.gz ${BINARY_URL}; \
cd /tmp; \
tar -xf /tmp/k6.tar.gz; \
mv /tmp/k6-v$K6_VERSION-linux-*/k6 /opt/utils/bin/; \
chmod +x /opt/utils/bin/k6; \
rm -rf /tmp/k6.tar.gz /tmp/k6-v$K6_VERSION-linux-*;
# Install uv
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -0,0 +1,597 @@
# Backend Performance Test Plan
**Context:** Build a k6-based load/performance test suite that simulates realistic browser-to-backend HTTP flows for distinct Penpot user operations. The goal is to measure backend impact (latency, throughput, error rates, resource saturation) under synthetic user load. **Browser rendering performance is explicitly out of scope.** WebSocket testing is deferred.
**Date:** 2026-06-12
**Validated Requirements:**
- Tool: **k6** (confirmed).
- Environment: flexible — local devenv first, then remote staging/perf.
- Target scale: **1000 concurrent VUs** (ramping from lower baselines).
- Flows: **realistic CRUD lifecycle** — create, edit, upload, delete. Must include **image upload** and **font upload**.
- `update-file` is important but difficult because it requires **23 concurrent users editing the same file**, and **file size matters**.
- WebSocket: **deferred**.
---
## Current Progress
### Completed (2026-06-12)
Phase 1 done. Phase 2 done (all core flows + performance optimization). Phase 3 done (orchestrator). Phase 4 done (concurrent editing + file size matrix). Phase 5 remains.
**What was built:**
```
performance/
├── run.sh # Bash runner — all commands + orchestrator
├── README.md # Usage docs, configuration, architecture notes
├── lib/
│ └── penpot-client.js # ~590 lines — shared k6 HTTP client module
├── scripts/
│ ├── lifecycle.js # Full user lifecycle (register → CRUD → delete)
│ ├── workspace-open.js # Read-heavy: file open loop (get-file, libraries, thumbnails)
│ ├── workspace-edit.js # Write-heavy: file edit loop (get-file + update-file)
│ ├── workspace-edit-concurrent.js # Concurrent editing: same-file or multi-file mode
│ ├── file-size-matrix.js # File size matrix: latency vs shape count (10, 100, 500, 1000)
│ ├── media-upload.js # Image uploads: SVG/PNG direct, JPG chunked
│ ├── font-upload.js # Font uploads: TTF+OTF chunked, create-font-variant
│ └── compare-results.cjs # Compare two k6 JSON results for regression
├── results/ # k6 JSON output (gitignored)
└── baselines/ # for regression baselines
```
Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in `performance/`).
**Backend changes:**
- `backend/src/app/rpc/commands/demo.clj` — demo profile emails changed from timestamp-based to UUID-based (eliminates collisions). Uses `derive-password-weak` for fast password hashing.
- `backend/src/app/auth.clj` — added `derive-password-weak` using pbkdf2+sha256 (100 iterations, ~0.13ms/hash, ~700x faster than argon2id). Safe for demo users because `demo-users` flag is disabled by default in production.
**All scripts use `setup()` user pool:**
| Script | setup() creates | VU pattern |
|--------|----------------|------------|
| `lifecycle.js` | N users | Each VU picks `users[__VU-1]` → login → full CRUD |
| `workspace-open.js` | 1 user + 1 file with shape | All VUs share same user + file (realistic concurrent reads) |
| `workspace-edit.js` | N users + shared project | Each VU creates own file → edit loop |
| `media-upload.js` | N users | Each VU creates project/file → upload 3 images |
| `font-upload.js` | N users | Each VU uploads TTF+OTF → create-font-variant |
Setup is sequential (~0.13ms/user with `derive-password-weak`), excluded from k6 metrics. At 1000 VUs: ~0.13s setup, then pure measurement.
**All flows validated (smoke test, 1 VU, 1 iteration each):**
| Script | Checks | Failure Rate |
|--------|--------|-------------|
| `lifecycle.js` | 10/10 | 0% |
| `workspace-open.js` | 9/9 | 0% |
| `workspace-edit.js` | 5/5 | 0% |
| `media-upload.js` | 8/8 | 0% |
| `font-upload.js` | 11/11 | 0% |
**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel, 0% failure rate.
**Key discoveries (cumulative):**
1. **JSON transport works.** Backend accepts `Content-Type: application/json` (kebab-case keys auto-converted) and returns `application/json` (camelCase keys) via `Accept: application/json` or `_fmt=json`. No Transit encoder needed.
2. **`create-file` `features` param.** Sending `features: []` causes 400. Omit entirely — it's optional (`backend/src/app/rpc/commands/files_create.clj`).
3. **`update-file` shape schema is strict.** The `add-obj` change requires: `selrect`, `points` (4 corners), `transform`/`transform-inverse` (identity matrix), `parentId`/`frameId` inside `obj`, and `frameId` at the change top level. Schema: `common/src/app/common/files/changes.cljc:189`.
4. **`update-file` URL convention.** `POST /api/main/methods/update-file?id=<uuid>``id` in both query string and body.
5. **Two registration modes:** `demo` (fast, needs `demo-users` flag) and `register` (two-step, no flags).
6. **k6 at** `/home/penpot/.local/bin/k6` (v0.56.0). Use `PATH="/home/penpot/.local/bin:$PATH"` or `K6` env var.
7. **Demo profile race condition — solved.** Backend now uses `uuid/next` for demo emails (no collisions). k6 scripts use `setup()` to create user pool before VUs start. Both changes together eliminate the scaling bottleneck.
8. **Chunked upload threshold.** The client uses 50 KB chunk size. Files ≤50 KB use direct multipart; files >50 KB use `create-upload-session``upload-chunk` × N → `assemble-file-media-object`.
9. **Font upload flow.** Each MIME type (ttf, otf, woff) gets its own `create-upload-session`. All session IDs are passed in the `uploads` map to `create-font-variant`. The `font-id` is a client-generated UUID that groups variants into a family.
10. **MIME type validation.** The backend validates that the uploaded content MIME matches the declared MIME. `sample.jpg` must be sent as `image/jpeg`, not `image/png`.
11. **workspace-open uses shared user.** All VUs read the same file with the same user. Multiple demo users can't access each other's files without team sharing, so a single shared user is the correct pattern for read-heavy tests.
12. **Demo profile creation was slow due to argon2id — now solved.** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2 (~94ms/hash). Created `derive-password-weak` using pbkdf2+sha256 with 100 iterations (~0.13ms/hash) — **~700x faster**. `demo.clj` now uses `derive-password-weak` for all demo profiles. Safe because `demo-users` is already a development-only feature (disabled by default in production). At 1000 VUs, setup time drops from ~23 min to ~0.13 sec.
13. **bcrypt minimum cost factor is 4.** Can't go below 4 for bcrypt. pbkdf2+sha256 with 100 iterations is even faster (~0.13ms/hash vs ~2.7ms for bcrypt cost 4) and was chosen instead. Benchmark: argon2id ~94ms/hash, bcrypt cost 4 ~2.7ms/hash, pbkdf2+sha256 100 iter ~0.13ms/hash.
14. **revn conflicts don't happen in normal concurrent editing.** The conflict check in `files_update.clj` is `(> incoming stored)` — only fires when incoming revn is *greater* than stored. If two VUs both read revn=5 and VU A saves first (revn becomes 6), VU B saves with revn=5 → `5 > 6?` → false → no conflict. The real contention point is the **file-level advisory lock** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file. More VUs = more lock queuing = higher latency.
15. **`update-file` response doesn't include `vern`.** The response is `{:revn N, :lagged [...]}`. `vern` only changes on snapshot restore, so it can be kept constant across iterations. Get it from the initial `get-file` call.
### Remaining Work
| Phase | Status | Next Actions |
|-------|--------|-------------|
| Phase 1 Discovery & Tooling | **Done** | — |
| Phase 2 Core HTTP Flows | **Done** | All 5 flows + orchestrator + setup() pool |
| Phase 2 Performance Optimization | **Done** | `derive-password-weak` using pbkdf2+sha256 (100 iter) — ~700x faster than argon2id |
| Phase 3 Scenarios | **Done** | `./run.sh all` runs all flows in parallel |
| Phase 4 Concurrent Editing | **Done** | `workspace-edit-concurrent.js` with same-file and multi-file modes |
| Phase 4 File Size Matrix | **Done** | `file-size-matrix.js` with 4 tiers (10, 100, 500, 1000 shapes) |
| Phase 5 Regression Guard | **Done** | `compare-results.cjs` + CI workflow (relative comparison) |
| Phase 5 Grafana Dashboards | **Deferred** | No Prometheus remote write or InfluxDB in current stack |
### Immediate Next Steps
1. ~~Phase 2 Fast password for demo users~~ ✅ Done
2. ~~Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes).~~ ✅ Done — `file-size-matrix.js` with 4 tiers
3. ~~Phase 4: Concurrent editing test (23 VUs per file, measure conflict rate).~~ ✅ Done — `workspace-edit-concurrent.js` with same-file and multi-file modes
4. ~~Phase 5: Regression guard — implement `compare-results.cjs` and CI workflow.~~ ✅ Done
5. ~~Add `--scenario` flag to `run.sh`~~ ✅ Done
6. Write `viewer.js``get-view-only-bundle` + `get-comment-threads` (deferred per user request).
---
## Affected Modules
| Module | Why it is involved |
|--------|---------------------|
| `backend/` | Target system. All HTTP RPC (`/api/main/methods/*`), auth, storage, media processing, DB, and Prometheus metrics (`/metrics`). |
| `frontend/` | Source of truth for user request flows. We inspect `app.main.repo` (RPC client), `app.main.data.*` (user flows), and `app.main.data.persistence` (save semantics). |
| `common/` | Shared schemas, Transit helpers, and data structures. Used to understand valid `update-file` `changes` payloads. |
---
## Approach
### Phase 1 Discovery & Tooling (Days 12)
#### 1.1. Read the frontend RPC flows to build a request catalog
Inspect these files to map every user action to its RPC command:
- `frontend/src/app/main/repo.cljs` — HTTP client conventions (headers, retry, GET vs POST rules, query params, form-data, multipart).
- `frontend/src/app/main/data/dashboard.cljs` — Dashboard init (`get-projects`, `fetch-fonts`, `search-files`).
- `frontend/src/app/main/data/workspace.cljs` — Workspace init (`get-file`, `get-file-libraries`, `get-file-object-thumbnails`, `resolve-file` via `get-file-fragment`).
- `frontend/src/app/main/data/persistence.cljs` — File save flow (`update-file` with `changes`, `revn`, `session-id`, debounce/buffer logic).
- `frontend/src/app/main/data/viewer.cljs` — Viewer flow (`get-view-only-bundle`).
- `frontend/src/app/main/data/comments.cljs` — Comment thread fetch (`get-comment-threads`).
- `frontend/src/app/main/data/media.cljs` / `upload.cljs` — Media upload flows (`upload-file-media-object`, `create-upload-session`, `upload-chunk`, `assemble-file-media-object`).
- `frontend/src/app/main/data/fonts.cljs` — Font upload flow (`create-font-variant` with `:uploads` map).
- `frontend/src/app/main/data/team.cljs` — Team creation (`create-team`), invitation (`create-team-invitations`).
- `frontend/src/app/main/data/project.cljs` — Project creation (`create-project`).
**Goal:** produce a **Request Catalog** mapping user actions to RPC command names, HTTP methods, payload shapes, and required preconditions (e.g., `team-id`, `file-id`).
#### 1.2. Confirm JSON compatibility for the test harness
The backend middleware (`app.http.middleware`) supports `application/json` request bodies and `application/json` responses (via `_fmt=json` or `Accept: application/json`).
- **Action:** Send a manual `curl` to `POST /api/main/methods/login-with-password` with `Content-Type: application/json` and verify the response format.
- **Action:** Verify `GET /api/main/methods/get-profile` with `Accept: application/json` returns plain JSON.
- **Action:** Verify `POST /api/main/methods/update-file` with `Content-Type: application/json` and `_fmt=json` works.
- **Action:** Verify `POST /api/main/methods/upload-file-media-object` with `multipart/form-data` works (k6 supports this natively).
#### 1.3. Set up the load testing directory and shared client
Create a directory `performance/` at the repo root.
Install **k6** (`k6` CLI or Docker image).
Create a shared `penpot-client.js` module that wraps:
- `login(email, password)` → returns session cookie / token.
- `rpc(cmd, params, opts)` → builds the correct URL, headers, body, and query params.
- `uploadFileMediaObject(fileId, filePath, name)` → multipart upload.
- `createUploadSession(totalChunks)` → chunked upload setup.
- `uploadChunk(sessionId, index, chunkBytes)` → multipart chunk upload.
- `assembleFileMediaObject(sessionId, fileId, name, isLocal)` → finalize chunked upload.
**Headers to replicate (critical for backend telemetry and session binding):**
- `x-session-id`: generated UUID per VU (must be consistent across requests for the same session).
- `x-external-session-id`: generated UUID per VU.
- `x-event-origin`: a string origin (e.g., `"perf-test"`)
- `accept`: `application/json` (for HTTP-only load path)
- `content-type`: `application/json` (or `multipart/form-data` for uploads)
- `credentials: "include"` (for cookie jar)
#### 1.4. Data seeding strategy for 1000 VU scale
Creating 1000 users/teams/files *inside* the load test is too slow and will distort the results.
**Recommended approach:**
- **Setup Phase (k6 `setup()`):** Run a pre-test script that creates a shared pool of test artifacts.
- Use `login-with-password` with a fixture account (e.g., `profile1@example.com` / `123123` if fixtures exist).
- Create `N` teams, `N` projects, `N` files of varying sizes (see **File Size Tiers** below).
- Export the IDs into a JSON file that k6 `setup()` reads.
- **Alternative:** Use the backend REPL / fixtures (`app.cli.fixtures/run {:preset :small}`) to create fixture data, then export the IDs via a small Clojure script.
- **Data pool per VU:** Each VU picks a random user from the pool, or uses a dedicated user (e.g., VU #1`profile1@example.com`, VU #2`profile2@example.com`). For 1000 VUs, we need at least 1000 pre-seeded users.
- **Cleanup:** A post-test script can delete the seeded data, or we can use a dedicated perf DB that is reset between runs.
**Action:** Document the seeding procedure in `performance/README.md` and create a `seed-data.js` script.
---
### Phase 2 Core HTTP Flow Scripts (Days 35)
Create one k6 script per user flow. Each script:
- Uses `setup()` to read the shared data pool and log in.
- Uses `vu` iterations to simulate the flow.
- Tags every request with the RPC command name so k6 metrics are sliced by endpoint.
- Uses `check()` assertions for HTTP 200 and valid JSON structure.
#### Flow 1: Realistic User Lifecycle (`lifecycle.js`)
This is the primary realistic flow. Each VU performs a full lifecycle:
1. **Auth**
- `POST /api/main/methods/login-with-password``{email, password}`
- `GET /api/main/methods/get-profile`
- `GET /api/main/methods/get-teams`
2. **Create Team**
- `POST /api/main/methods/create-team``{name: "Perf Team <uuid>"}`
- `GET /api/main/methods/get-team?team-id=<id>`
3. **Create Project**
- `POST /api/main/methods/create-project``{team-id, name}`
- `GET /api/main/methods/get-project?id=<id>`
4. **Create File**
- `POST /api/main/methods/create-file``{project-id, name, features}`
- `GET /api/main/methods/get-file?id=<file-id>&features=<...>`
5. **Edit File (Simple Update)**
- `POST /api/main/methods/update-file` with a minimal `changes` payload.
- **Changes payload:** Use a simple change like `{:type "add-obj", :id "<uuid>", :page-id "<page-id>", :parent-id "<parent-id>", :obj {:type "rect", ...}}`. Inspect `app.common.files.changes` for the exact schema. For a load test, we only need the shape to be structurally valid; the backend validates it.
- **Revn tracking:** Fetch the file first, read `revn`, then send `revn` in the update. If a conflict occurs (`409` or `:revn-conflict` error), retry once with the latest `revn`.
6. **Upload Image (Direct)**
- `POST /api/main/methods/upload-file-media-object` (multipart)
- Payload: `file-id`, `is-local: true`, `name`, `content` (the file bytes).
- Use a small dummy PNG/SVG (e.g., 1 KB, 100 KB, 1 MB) stored in `performance/fixtures/`.
7. **Upload Image (Chunked)**
- `POST /api/main/methods/create-upload-session``{total-chunks: N}`
- Loop `N` times: `POST /api/main/methods/upload-chunk` (multipart, `session-id`, `index`, `chunk`)
- `POST /api/main/methods/assemble-file-media-object``{session-id, file-id, name, is-local}`
- Use a larger dummy file (e.g., 5 MB) to stress the chunked pipeline.
8. **Upload Font**
- `POST /api/main/methods/create-upload-session` (chunked, because fonts can be large)
- `POST /api/main/methods/upload-chunk` for each chunk
- `POST /api/main/methods/create-font-variant``{team-id, font-id, font-family, font-weight, font-style, uploads: {"font/ttf": "<session-id>"}}`
- Use a small real TTF/OTF file from `performance/fixtures/`.
9. **Delete File**
- `DELETE /api/main/methods/delete-file` (verify the exact method name; it may be `update-file` with a deletion flag or a dedicated command). Inspect `frontend/src/app/main/data/dashboard.cljs` for the delete action.
10. **Delete Project**
- `DELETE /api/main/methods/delete-project?id=<id>`
11. **Delete Team**
- `POST /api/main/methods/delete-team?id=<id>`
12. **Logout**
- (Optional; session cookie expiry is usually sufficient)
**Pacing:** Add `sleep()` between steps to simulate realistic think time (e.g., 13 seconds between dashboard navigation, 35 seconds between edits).
#### Flow 2: Workspace Open (Read-heavy) (`workspace-open.js`)
For 1000 VUs, most will be read-only viewers or editors opening files.
1. Login (reuse token from `setup`).
2. `GET /api/main/methods/get-file?id=<file-id>&features=<...>`
3. `GET /api/main/methods/get-file-libraries?file-id=<file-id>`
4. For each library: `GET /api/main/methods/get-file?id=<lib-id>`
5. `GET /api/main/methods/get-file-object-thumbnails?file-id=<file-id>`
6. `GET /api/main/methods/get-file-data-for-thumbnail?file-id=<file-id>&page-id=<page-id>&object-id=<frame-id>`
**Data:** Use a pool of files of varying sizes (see **File Size Tiers**).
#### Flow 3: Workspace Edit (Write-heavy) (`workspace-edit.js`)
**Scenario A — Independent editors (default, easiest to scale):**
- Each VU creates its own file in `setup()`, or picks a dedicated file from the pool.
- Loop:
1. `GET /api/main/methods/get-file?id=<file-id>` (to refresh `revn`)
2. `POST /api/main/methods/update-file` with minimal changes
3. `sleep(3)`
- This measures the latency of the save path without concurrency conflicts.
**Scenario B — Concurrent editors (advanced, measures conflict resolution):**
- 23 VUs share the **same file ID**.
- Each VU:
1. `GET /api/main/methods/get-file?id=<file-id>` (to get latest `revn`)
2. `POST /api/main/methods/update-file` with changes
3. If `revn-conflict` (HTTP 400 or 409 with `:code :revn-conflict`), retry with the latest `revn`.
- **Problem:** k6 VUs are independent; they cannot easily share a mutable `revn` counter.
- **Solutions:**
1. **Optimistic concurrency:** Let conflicts happen naturally. Measure the conflict rate and retry latency. This is realistic for many-user editing.
2. **Shared state service:** Run a tiny Redis or in-memory service that stores the latest `revn` per file. VUs read/write it before each update. This adds coordination overhead but reduces conflicts.
3. **Sequential VU groups:** Use k6 `scenarios` with `executor: 'per-vu-iterations'` and a small shared file pool. Accept that some conflicts will occur and measure them as part of the benchmark.
- **Recommendation:** Start with **Solution 1** (optimistic). If the conflict rate is >10%, consider **Solution 2**.
**File Size Tiers for `update-file`:**
The backend `update-file` performance depends heavily on file data size (serialization, validation, pointer-map resolution, snapshotting).
| Tier | Size | How to create |
|------|------|---------------|
| Small | ~10 shapes | Create a file with a few rectangles. |
| Medium | ~100 shapes | Duplicate a page with many shapes. |
| Large | ~1000 shapes | Import a real-world design file or use a fixture. |
**Action:** Create a `create-file-fixture.js` helper that generates files of each tier via the `create-file` + `update-file` API (or by importing a `.penpot` file via the binfile import API if available).
#### Flow 4: Viewer (Read-heavy, anonymous or logged-in) (`viewer.js`)
1. Login (or use share-link token for anonymous).
2. `GET /api/main/methods/get-view-only-bundle?file-id=<id>&share-id=<id>&features=<...>`
3. `GET /api/main/methods/get-comment-threads?file-id=<id>&share-id=<id>`
#### Flow 5: Export (CPU/IO-heavy) (`export.js`)
1. Login.
2. `POST /api/export` with export payload.
- Inspect `frontend/src/app/main/data/export.cljs` for the exact payload shape.
- Common exports: `type: "png"`, `type: "svg"`, `type: "pdf"`.
- This hits the **exporter** service (Node.js/Playwright), which is a separate process. If the goal is to stress the **backend**, limit export tests or target the backend export queue endpoints.
#### Flow 6: Media Upload (Storage/IO-heavy) (`media-upload.js`)
1. Login.
2. Direct upload: `POST /api/main/methods/upload-file-media-object` (multipart, small PNG).
3. Chunked upload: `POST /api/main/methods/create-upload-session``upload-chunk` x N → `assemble-file-media-object` (large PNG).
4. URL-based upload: `POST /api/main/methods/create-file-media-object-from-url` (if a stable external image URL is available).
#### Flow 7: Font Upload (Storage/CPU-heavy) (`font-upload.js`)
1. Login.
2. `POST /api/main/methods/create-upload-session` (for the font file)
3. `POST /api/main/methods/upload-chunk` for each chunk
4. `POST /api/main/methods/create-font-variant``{team-id, font-id, font-family, font-weight, font-style, uploads: {"font/ttf": "<session-id>"}}`
5. `GET /api/main/methods/get-font-variants?team-id=<id>`
---
### Phase 2 Performance Optimization: Fast Password Hashing for Demo Users ✅ Done
**Goal:** Reduce `setup()` time for performance tests by making demo profile password derivation faster.
**Problem:** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2 (~94ms/hash). At 1000 VUs, creating the user pool in `setup()` takes ~23 minutes just for password hashing.
**Solution:**
Since `demo-users` is already a development-only feature (disabled by default in production), all demo profiles use a weaker, faster password algorithm. No special parameters or tenant checks needed.
1. In `backend/src/app/auth.clj`, added `derive-password-weak` using pbkdf2+sha256 with 100 iterations (~0.13ms/hash — **~700x faster** than argon2id).
2. In `backend/src/app/rpc/commands/demo.clj`, switched from `derive-password` to `derive-password-weak`.
**Files touched:**
- `backend/src/app/auth.clj` — added `weak-options` (pbkdf2+sha256, 100 iter) and `derive-password-weak`
- `backend/src/app/rpc/commands/demo.clj` — uses `derive-password-weak` instead of `derive-password`
**Impact:** Setup time for 1000 users dropped from ~23 min to ~0.13 sec (~700x improvement).
**Safety:** Demo users are already a development-only feature (disabled by default in production via `demo-users` config flag). Using weaker passwords for demo users only affects development/test environments where the flag is explicitly enabled.
---
### Phase 3 Scenarios & Orchestration (Day 6)
Define k6 `options.scenarios` that mix the flows to simulate realistic traffic.
**Example scenario mix for 1000 VUs:**
| Scenario | Script | VUs | Arrival Rate | Duration | Notes |
|----------|--------|-----|--------------|----------|-------|
| `lifecycle` | `lifecycle.js` | 100 | 1/s (ramp 0→100 over 5m) | 10m | Full CRUD, most realistic. |
| `workspace_open` | `workspace-open.js` | 400 | 5/s (ramp 0→400 over 5m) | 10m | Read-heavy, simulates many editors opening files. |
| `workspace_edit` | `workspace-edit.js` | 200 | 2/s (ramp 0→200 over 5m) | 10m | Write-heavy, independent files. |
| `workspace_edit_concurrent` | `workspace-edit.js` | 30 (10 groups of 3) | 0.5/s | 10m | 3 VUs per file, measures conflicts. |
| `viewer` | `viewer.js` | 200 | 3/s (ramp 0→200 over 5m) | 10m | Read-heavy, simulates public/private viewers. |
| `media_upload` | `media-upload.js` | 50 | 0.5/s | 10m | Storage stress. |
| `font_upload` | `font-upload.js` | 20 | 0.2/s | 10m | Font processing stress. |
**Thresholds:**
- `http_req_duration{p95} < 200ms` for `get-profile`, `get-teams`, `get-projects`.
- `http_req_duration{p95} < 500ms` for `get-file` (small), `search-files`.
- `http_req_duration{p95} < 2000ms` for `get-file` (large / 1000 shapes).
- `http_req_duration{p95} < 1000ms` for `update-file` (small).
- `http_req_duration{p95} < 3000ms` for `update-file` (large).
- `http_req_duration{p95} < 5000ms` for `upload-file-media-object` (1 MB).
- `http_req_duration{p95} < 10000ms` for `assemble-file-media-object` (5 MB chunked).
- `http_req_failed < 1%` globally.
- `http_req_failed{code:revn-conflict} < 5%` for `workspace_edit_concurrent`.
**Correlation with backend metrics:**
- Scrape `/metrics` before, during, and after the test.
- Key Prometheus metrics to watch:
- `rpc_main_timing_seconds` (histogram/summary, labeled by command name)
- `rpc_management_timing_seconds`
- `http_server_dispatch_timing_seconds`
- `websocket_active_connections` (if any WS is active)
- `websocket_messages_total`
- JVM hotspot metrics (`process_cpu_seconds_total`, `jvm_memory_bytes_used`, `jvm_threads_current`)
- HikariCP metrics (if exposed; check `com.zaxxer.hikari:type=Pool` via JMX or custom Prometheus exporter)
- PostgreSQL: `pg_stat_activity` count by state.
- Redis: `INFO` `connected_clients`, `used_memory`.
---
### Phase 4 Advanced `update-file` Testing (Days 78)
Because `update-file` is the core of the product and the user explicitly noted that **file size matters** and **concurrent editing is difficult**, we need a dedicated deep-dive.
#### 4.1. File Size Tiers
Create a `file-size-matrix.js` script that parameterizes the file size:
- `SMALL_FILE_ID`: 1 page, 10 shapes.
- `MEDIUM_FILE_ID`: 1 page, 100 shapes.
- `LARGE_FILE_ID`: 1 page, 500 shapes.
- `XLARGE_FILE_ID`: 1 page, 1000+ shapes, or a multi-page file.
Run `workspace-edit.js` against each tier separately and plot:
- `update-file` latency vs file size.
- `get-file` latency vs file size.
- Backend CPU and DB time vs file size.
#### 4.2. Concurrent Editing — Two Modes
**Key insight:** `revn` conflicts only occur when `incoming > stored` (should never happen in normal usage). The real contention point is the **file-level advisory lock** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file.
**Mode 1: Same-file** — N VUs edit different pages in 1 file
- Measures lock contention on a single popular file
- Bottleneck: advisory lock serialization
**Mode 2: Multi-file** — G groups × M VUs per file, each group edits its own file
- Measures whole system responsiveness under parallel edit sessions
- Bottleneck: DB connection pool, CPU, memory
- More realistic: real usage has many files being edited concurrently
**Script:** `workspace-edit-concurrent.js`
**Configuration via env vars:**
- `PENPOT_EDIT_MODE=same-file | multi-file` (default: `same-file`)
- `PENPOT_FILE_COUNT=1` — number of files (for multi-file mode)
- `PENPOT_VUS_PER_FILE=3` — VUs per file (for multi-file mode)
**Setup logic:**
- `same-file`: create 1 file, add N pages (N = total VUs)
- `multi-file`: create G files, each with M pages (G = FILE_COUNT, M = VUS_PER_FILE)
**VU loop:**
1. Login with assigned user
2. Get file → pick assigned page
3. Loop (10 iterations):
- `get-file` → get latest `revn`
- `sleep(0.3)` (think time)
- `update-file` with change to assigned page (add rectangle)
- Track: success on first try (should always succeed)
- `sleep(1)` (edit pacing)
**Scenario ladder — same-file mode:**
| Run | VUs | Iterations | What we measure |
|-----|-----|-----------|-----------------|
| 1 | 3 | 10 | Baseline lock contention |
| 2 | 5 | 10 | Moderate contention |
| 3 | 10 | 10 | Higher contention |
| 4 | 20 | 10 | Stress level |
**Scenario ladder — multi-file mode:**
| Run | Files | VUs/file | Total VUs | What we measure |
|-----|-------|----------|-----------|-----------------|
| 1 | 3 | 2 | 6 | Light load |
| 2 | 5 | 3 | 15 | Moderate |
| 3 | 10 | 3 | 30 | Heavy |
| 4 | 10 | 5 | 50 | Stress |
**Metrics to track:**
- `http_req_duration{rpc_command:update-file}` — p50, p95, p99 at each VU level
- `http_req_duration{rpc_command:get-file}` — should be unaffected
- `http_req_failed` — should be 0%
- Latency growth curve: how much does p95 increase per additional VU?
**Expected results:**
- `get-file` latency: constant (no lock, read-only)
- `update-file` p95: grows with VU count in same-file mode (lock queuing)
- `update-file` p95: stable in multi-file mode (independent locks)
- Failure rate: 0% (no revn conflicts in this scenario)
**Files to create:**
- `performance/scripts/workspace-edit-concurrent.js`
**Files to modify:**
- `performance/run.sh` — add `concurrent-edit` command
---
### Phase 5 CI Integration & Reporting (Days 910)
1. **Runner script (`run.sh`):**
- `./run.sh smoke` for a 1-VU, 1-iteration smoke test. ✅ Done
- `./run.sh lifecycle -v 100 -n 10` for the standard run.
- Add `--scenario` flag to run individual flows or the full mix. ✅ Done
2. **Output:**
- k6 JSON/CSV output to `performance/results/<timestamp>/`.
- Prometheus snapshot diff (before vs after).
- Grafana screenshot or dashboard export.
3. **Grafana Dashboard:** *(Deferred — no Prometheus remote write or InfluxDB configured in current stack)*
- Panel: `p95 latency by RPC command` (from `rpc_main_timing_seconds`).
- Panel: `HTTP requests/sec` (from k6).
- Panel: `Error rate by command` (from k6).
- Panel: `DB connection pool` (if available).
- Panel: `JVM heap used`.
- Panel: `update-file conflict rate` (custom metric from k6).
- Panel: `File size vs latency` (from the matrix test).
4. **Regression guard (relative comparison):**
- **Approach:** Run performance tests twice in the same CI job — once on base branch, once on PR branch. Compare p95/p99 directly. No stored baselines needed.
- **Trigger:** Only when backend files change (`backend/src/**`).
- **Comparison script:** `scripts/compare-results.js` — parses two k6 JSON outputs, compares p50/p95/p99 for each RPC command.
- **Threshold:** Fail if p95 increases >20% for any critical command (`get-file`, `update-file`, `login-with-password`, `create-demo-profile`).
- **Workflow:**
1. Checkout base branch (main)
2. Run performance tests → store as "baseline"
3. Checkout PR branch
4. Run performance tests → store as "current"
5. Compare baseline vs current
6. If p95 increases >20% → fail CI
- **Advantages:** Same hardware, same conditions. No stored baselines. Only runs when backend changes.
---
## Risks & Considerations
| Risk | Mitigation |
|------|------------|
| **Scale: 1000 VUs creating data simultaneously will exhaust DB connection pool or storage quota.** | Pre-seed the data pool. Use a dedicated perf DB. Monitor `pg_stat_activity` and HikariCP metrics. |
| **Media upload (images/fonts) will saturate network I/O before the backend is stressed.** | Run the load test from the same datacenter/VPC as the backend. Use small dummy files for most tests; reserve large files for a dedicated storage-stress scenario. |
| **`update-file` conflicts under 1000 VUs may be so high that the test becomes a conflict test, not a latency test.** | Measure both. The conflict rate is itself a critical metric. If it is too high, we can add jitter or use independent files. |
| **Exporter service is a separate bottleneck.** | `export.js` should target the backend queue endpoint, not the full export pipeline, unless we want to test the exporter too. If exporter is in scope, run it as a separate scenario. |
| **Chunked upload creates many temporary DB rows (`upload_chunk` table).** | The backend has a `upload-session-gc` cron job. Ensure it runs after the test, or clean up manually. |
| **Font upload shells out to FontForge and WOFF tools.** | This is CPU-intensive and may be a bottleneck. Run font upload as a separate, low-VU scenario to measure the processing time without blocking other tests. |
| **Prometheus metrics may not expose DB pool wait time.** | Add a custom JMX exporter for HikariCP if needed, or query `pg_stat_activity` directly. |
| **Cleanup:** 1000 VUs creating teams/files will leave logical deletions or orphaned storage objects.** | Use a dedicated perf environment. Run a cleanup script after the test that deletes all seeded data via the RPC API. |
---
## Testing Strategy
### How to verify the test harness itself works
1. **Smoke test:** Run each k6 script with `1 VU, 1 iteration` against a local devenv. Verify all requests return `200` and the response body is valid JSON.
2. **Baseline run:** Run `workspace-open.js` with `10 VUs, 60 s` against a clean devenv. Record baseline p95 and p99 latencies.
3. **Regression guard:** After any backend change, re-run the baseline. If p95 increases by >20%, flag it.
4. **Saturation test:** Ramp `workspace-edit.js` to 100 VUs editing independent files. Monitor backend CPU and DB connection pool. The test should reveal the breaking point where `update-file` latency spikes.
5. **Media upload stress test:** Run `media-upload.js` with 50 VUs uploading 1 MB files. Verify storage throughput and no `413` errors.
6. **Font upload stress test:** Run `font-upload.js` with 10 VUs. Verify FontForge CPU usage and no timeouts.
### Manual validation checklist
- [x] `POST /api/main/methods/login-with-password` with JSON body returns a session cookie. (Validated via k6 lifecycle)
- [x] `GET /api/main/methods/get-profile` with `Accept: application/json` returns JSON. (Validated via k6 lifecycle)
- [ ] `curl -H "Accept: application/json" http://localhost:6060/metrics` returns Prometheus text.
- [ ] Backend fixtures create at least 100 test users and 100 test files.
- [x] A `update-file` request with a minimal `changes` payload succeeds and returns `{"revn": N}`. (Validated — needs full shape with selrect, points, transform, frame-id)
- [x] A `upload-file-media-object` multipart request succeeds and returns a media object ID. (Validated via k6 lifecycle + media-upload)
- [x] A chunked upload (`create-upload-session``upload-chunk``assemble-file-media-object`) succeeds. (Validated via media-upload with JPG 305 KB, and font-upload with TTF 68 KB + OTF 82 KB)
- [x] A `create-font-variant` request with chunked uploads succeeds. (Validated via font-upload — TTF + OTF, returns variant with id)
---
## Immediate Next Steps (if approved)
1. ~~Create `performance/` directory and `README.md`.~~ ✅ Done
2. ~~Write `penpot-client.js` (k6 shared module) with `login()`, `rpc()`, `uploadMultipart()`, and `uploadChunked()` helpers.~~ ✅ Done (~590 lines, JSON transport, cookie auth, session headers, tagged metrics, direct + chunked upload, file library/thumbnail methods)
3. ~~Write a manual `curl` validation script~~ — Skipped; JSON compatibility confirmed via k6 smoke test.
4. ~~Write a data seeding script~~ — Not needed. User pool created in k6 `setup()` phase (sequential, excluded from metrics). Each VU picks `data.users[__VU - 1]` to login.
5. ~~Write the first k6 script: `lifecycle.js`~~ ✅ Done (11 checks, 22 HTTP requests, 0% failure)
6. ~~Run a 1-VU smoke test against local devenv and commit the baseline results.~~ ✅ Done
7. ~~Write `workspace-open.js` and `workspace-edit.js`.~~ ✅ Done (both validated, 0% failure)
8. ~~Write `media-upload.js` and `font-upload.js`.~~ ✅ Done (both validated, 0% failure)
9. ~~Define the `1000-VU` scenario mix in `options.js` (shared scenario config).~~ ✅ Done (`./run.sh all` orchestrator runs all 5 flows in parallel)
10. Run the first 100-VU ramp test and capture Prometheus metrics.
---
**Plan Author:** Senior Software Architect
**Status:** Phase 15 complete. Regression guard implemented (relative comparison). Grafana dashboards deferred.