diff --git a/performance/fixtures/test-large.png b/performance/fixtures/test-large.png deleted file mode 100644 index fbb82a92ac..0000000000 Binary files a/performance/fixtures/test-large.png and /dev/null differ diff --git a/performance/fixtures/test-medium.png b/performance/fixtures/test-medium.png deleted file mode 100644 index 0094599ffe..0000000000 Binary files a/performance/fixtures/test-medium.png and /dev/null differ diff --git a/performance/fixtures/test-small.png b/performance/fixtures/test-small.png deleted file mode 100644 index 5391345596..0000000000 Binary files a/performance/fixtures/test-small.png and /dev/null differ diff --git a/performance/lib/penpot-client.js b/performance/lib/penpot-client.js index 18df142255..f59ee3134e 100644 --- a/performance/lib/penpot-client.js +++ b/performance/lib/penpot-client.js @@ -219,6 +219,57 @@ export function createClient(baseUrl) { }; } + /** + * 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. * @@ -511,6 +562,9 @@ export function createClient(baseUrl) { createProject, createFile, getFile, + getFileLibraries, + getFileObjectThumbnails, + getFileDataForThumbnail, updateFile, uploadFileMediaObject, uploadFileMediaObjectDirect, diff --git a/performance/run.sh b/performance/run.sh index 48f890e202..337b1466bd 100755 --- a/performance/run.sh +++ b/performance/run.sh @@ -10,9 +10,12 @@ # # Usage: # ./run.sh smoke # 1 VU, 1 iteration smoke test -# ./run.sh lifecycle # 1 VU, 1 iteration (defaults) -# ./run.sh lifecycle -v 10 -n 5 # 10 VUs, 5 iterations each -# ./run.sh lifecycle -u http://remote:6060 -m register -v 5 +# ./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 all # Run all scenarios together (orchestrator) # ./run.sh clean # Remove test results set -euo pipefail @@ -24,8 +27,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # --------------------------------------------------------------------------- BASE_URL="${PENPOT_BASE_URL:-http://localhost:6060}" -VUS=1 -ITER=1 +VUS="" +ITER="" REGISTER_MODE="${PENPOT_REGISTER_MODE:-demo}" K6="${K6:-k6}" @@ -41,15 +44,20 @@ Usage: $(basename "$0") [options] Commands: - smoke 1 VU, 1 iteration smoke test (forces demo mode) - lifecycle Full user lifecycle test - clean Remove test results - help Show this help + 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 + all Run all scenarios together (orchestrator) + clean Remove test results + help Show this help -Options (lifecycle only): +Options: -u URL Backend base URL (default: $BASE_URL) - -v NUM Number of virtual users (default: $VUS) - -n NUM Iterations per VU (default: $ITER) + -v NUM Number of virtual users (default: per-script defaults) + -n NUM Iterations per VU (default: per-script defaults) -m MODE Register mode: 'demo' or 'register' (default: $REGISTER_MODE) -k PATH Path to k6 binary (default: $K6) @@ -61,8 +69,9 @@ Environment variables: Examples: $(basename "$0") smoke $(basename "$0") lifecycle -v 10 -n 5 - $(basename "$0") lifecycle -m register -v 5 -n 1 - $(basename "$0") lifecycle -u https://penpot.example.com + $(basename "$0") workspace-edit -v 20 -n 50 + $(basename "$0") media-upload -u https://penpot.example.com + $(basename "$0") all -v 50 EOF } @@ -74,25 +83,127 @@ check_k6() { fi } -run_k6() { - local results_dir="$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)" +# Build k6 env flags +k6_env_flags() { + echo "--env PENPOT_BASE_URL=$BASE_URL --env PENPOT_REGISTER_MODE=$REGISTER_MODE" +} + +# Build k6 VU/iteration 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" + 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 "Penpot Performance Test" - echo " Base URL: $BASE_URL" - echo " Register mode: $REGISTER_MODE" - echo " VUs: $VUS" - echo " Iterations: $ITER" - echo " Results: $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 "" - "$K6" run \ - --env "PENPOT_BASE_URL=$BASE_URL" \ - --env "PENPOT_REGISTER_MODE=$REGISTER_MODE" \ - --vus "$VUS" \ - --iterations "$ITER" \ + # shellcheck disable=SC2046 + $K6 run \ + $(k6_env_flags) \ + $(k6_scale_flags) \ --out "json=$results_dir/k6-summary.json" \ - "$SCRIPT_DIR/scripts/lifecycle.js" + "$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" } # --------------------------------------------------------------------------- @@ -104,26 +215,15 @@ cmd_smoke() { REGISTER_MODE=demo VUS=1 ITER=1 - run_k6 + run_script "lifecycle.js" "smoke" } -cmd_lifecycle() { - # Parse options - while getopts "u:v:n:m:k:h" opt; do - case "$opt" in - u) BASE_URL="$OPTARG" ;; - v) VUS="$OPTARG" ;; - n) ITER="$OPTARG" ;; - m) REGISTER_MODE="$OPTARG" ;; - k) K6="$OPTARG" ;; - h) usage; exit 0 ;; - *) usage >&2; exit 1 ;; - esac - done - - check_k6 - run_k6 -} +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_clean() { local results_dir="$SCRIPT_DIR/results" @@ -139,6 +239,21 @@ cmd_clean() { # Main # --------------------------------------------------------------------------- +# Parse global options first (before command) +parse_opts() { + while getopts "u:v:n:m:k:h" opt; do + case "$opt" in + u) BASE_URL="$OPTARG" ;; + v) VUS="$OPTARG" ;; + n) ITER="$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 @@ -147,11 +262,27 @@ fi command="$1" shift +# Parse options for flow commands (not smoke/clean/help/all) case "$command" in - smoke) cmd_smoke "$@" ;; - lifecycle) cmd_lifecycle "$@" ;; - clean) cmd_clean "$@" ;; - help|-h|--help) usage ;; + smoke|clean|help|-h|--help) + ;; + *) + parse_opts "$@" + # Consume parsed opts + while getopts "u:v:n:m:k:h" _ 2>/dev/null; do shift $((OPTIND - 1)); done 2>/dev/null || true + ;; +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 ;; + all) cmd_all ;; + clean) cmd_clean ;; + help|-h|--help) usage ;; *) echo "Unknown command: $command" >&2 usage >&2 diff --git a/performance/scripts/font-upload.js b/performance/scripts/font-upload.js new file mode 100644 index 0000000000..76200dc8b8 --- /dev/null +++ b/performance/scripts/font-upload.js @@ -0,0 +1,216 @@ +// Font Upload Performance Test +// +// Tests the font upload flow: chunked upload of a TTF file followed by +// creating a font variant. This exercises the storage pipeline and +// font processing (FontForge/WOFF conversion). +// +// Flow: +// 1. Register → login → get team +// 2. Chunked upload of font-1.ttf (68 KB, 2 chunks at 50 KB) +// 3. create-font-variant with the uploaded session +// +// Usage: +// k6 run scripts/font-upload.js +// k6 run --vus 3 --iterations 2 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 = { + scenarios: { + font_upload: { + executor: "per-vu-iterations", + vus: 1, + iterations: 1, + maxDuration: "2m", + }, + }, + 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 — load font fixtures from backend test files +// --------------------------------------------------------------------------- + +const fontTtf = open("../../backend/test/backend_tests/test_files/font-1.ttf", "b"); +const fontOtf = open("../../backend/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 +// --------------------------------------------------------------------------- + +export function setup() { + console.log(`Penpot Font Upload Test`); + console.log(` Base URL: ${BASE_URL}`); + console.log(` Font TTF: ${fontTtf.byteLength} B`); + console.log(` Font OTF: ${fontOtf.byteLength} B`); + console.log(``); + + const client = createClient(BASE_URL); + + const res = client.getProfile(); + if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`); + + return { baseUrl: BASE_URL }; +} + +// --------------------------------------------------------------------------- +// Main VU Function +// --------------------------------------------------------------------------- + +export default function (data) { + const client = createClient(data.baseUrl); + + // Register + const demoRes = client.rpc("POST", "create-demo-profile", {}); + if (!assertOk(demoRes, "create-demo-profile")) fail("Failed to create demo profile"); + const demo = demoRes.json(); + + // Login + const loginRes = client.login(demo.email, demo.password); + if (!assertOk(loginRes, "login")) fail("Login failed"); + const teamId = loginRes.body.defaultTeamId; + + sleep(0.5); + + // Get teams (to confirm team-id) + const teamsRes = client.getTeams(); + if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed"); + const confirmedTeamId = teamsRes.body[0].id; + + // Upload TTF via chunked upload + const fontId = uuidv4(); + const fontFamily = `PerfFont-${uuidv4().substring(0, 8)}`; + + console.log(`VU ${__VU}: Uploading font "${fontFamily}" (font-id: ${fontId})`); + + // Create upload session for TTF + const chunkSize = 50 * 1024; // 50 KB — matches client default + const ttfChunks = Math.ceil(fontTtf.byteLength / chunkSize); + + const sessionRes = client.createUploadSession(ttfChunks); + if (!assertOk(sessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed"); + const ttfSessionId = sessionRes.sessionId; + + sleep(0.2); + + // Upload TTF chunks + for (let i = 0; i < ttfChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, fontTtf.byteLength); + const chunk = fontTtf.slice(start, end); + + const chunkRes = client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"); + if (!assertOk(chunkRes, `upload-chunk (ttf ${i}/${ttfChunks})`)) { + fail(`Chunk upload failed at index ${i}`); + } + + sleep(0.1); + } + + console.log(`VU ${__VU}: Uploaded TTF in ${ttfChunks} chunks`); + + // Upload OTF via chunked upload (separate session) + 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; + + sleep(0.2); + + for (let i = 0; i < otfChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, fontOtf.byteLength); + const chunk = fontOtf.slice(start, end); + + const chunkRes = client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"); + if (!assertOk(chunkRes, `upload-chunk (otf ${i}/${otfChunks})`)) { + fail(`OTF chunk upload failed at index ${i}`); + } + + sleep(0.1); + } + + console.log(`VU ${__VU}: Uploaded OTF in ${otfChunks} chunks`); + + sleep(0.5); + + // Create font variant + const variantRes = client.rpc("POST", "create-font-variant", { + "team-id": confirmedTeamId, + "font-id": fontId, + "font-family": fontFamily, + "font-weight": 400, + "font-style": "normal", + uploads: { + "font/ttf": ttfSessionId, + "font/otf": otfSessionId, + }, + }); + + if (!assertOk(variantRes, "create-font-variant")) { + fail("create-font-variant failed"); + } + + console.log(`VU ${__VU}: Created font variant "${fontFamily}" (weight: 400, style: normal)`); + + // Verify by fetching font variants + sleep(0.5); + const getVariantsRes = client.rpc("GET", "get-font-variants", { + "team-id": confirmedTeamId, + }); + if (assertOk(getVariantsRes, "get-font-variants")) { + const variants = getVariantsRes.json(); + console.log(`VU ${__VU}: Team has ${Array.isArray(variants) ? variants.length : "?"} font variants`); + } + + console.log(`VU ${__VU}: Font upload test complete`); +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown(data) { + console.log("Font upload test complete."); +} diff --git a/performance/scripts/lifecycle.js b/performance/scripts/lifecycle.js index 8ba13f06e4..5e3e3217a9 100644 --- a/performance/scripts/lifecycle.js +++ b/performance/scripts/lifecycle.js @@ -62,8 +62,8 @@ export const options = { // --------------------------------------------------------------------------- // Load test PNG fixtures — small uses direct upload, large uses chunked upload -const testImageSmall = open("../fixtures/test-small.png", "b"); -const testImageLarge = open("../fixtures/test-large.png", "b"); +const testImageSmall = open("../../backend/test/backend_tests/test_files/sample.png", "b"); +const testImageLarge = open("../../backend/test/backend_tests/test_files/sample.jpg", "b"); // A minimal "add-obj" change payload for update-file. // This adds a simple rectangle shape to the first page. @@ -177,6 +177,10 @@ export function setup() { export default function (data) { const client = createClient(data.baseUrl); + // Small random delay to spread out concurrent demo profile creation + // (demo emails are timestamp-based and can collide within the same ms) + sleep(Math.random() * 2); + // ---- Step 0: Create a user account ---- let email, password; @@ -366,8 +370,8 @@ export default function (data) { const uploadRes = client.uploadFileMediaObject( fileId, testImageLarge, - "test-large.png", - "image/png" + "sample.jpg", + "image/jpeg" ); if (assertOk(uploadRes, "upload-file-media-object (chunked)")) { console.log(`VU ${__VU}: Uploaded large image (chunked)`); diff --git a/performance/scripts/media-upload.js b/performance/scripts/media-upload.js new file mode 100644 index 0000000000..f9989c19c8 --- /dev/null +++ b/performance/scripts/media-upload.js @@ -0,0 +1,177 @@ +// 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) +// +// Flow: +// 1. Register → login → create project → create file +// 2. Upload SVG (direct) +// 3. Upload PNG (direct) +// 4. Upload JPG (chunked) +// +// Usage: +// k6 run scripts/media-upload.js +// k6 run --vus 5 --iterations 3 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 = { + scenarios: { + media_upload: { + executor: "per-vu-iterations", + vus: 1, + iterations: 1, + maxDuration: "2m", + }, + }, + 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 — load fixtures from backend test files +// --------------------------------------------------------------------------- + +const imageSvg = open("../../backend/test/backend_tests/test_files/sample1.svg", "b"); +const imagePng = open("../../backend/test/backend_tests/test_files/sample.png", "b"); +const imageJpg = open("../../backend/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 +// --------------------------------------------------------------------------- + +export function setup() { + console.log(`Penpot Media Upload Test`); + console.log(` Base URL: ${BASE_URL}`); + console.log(` Fixtures:`); + console.log(` SVG: ${imageSvg.byteLength} B`); + console.log(` PNG: ${imagePng.byteLength} B`); + console.log(` JPG: ${imageJpg.byteLength} B`); + console.log(``); + + const client = createClient(BASE_URL); + + // Verify backend is reachable + const res = client.getProfile(); + if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`); + + return { baseUrl: BASE_URL }; +} + +// --------------------------------------------------------------------------- +// Main VU Function +// --------------------------------------------------------------------------- + +export default function (data) { + const client = createClient(data.baseUrl); + + // Register + const demoRes = client.rpc("POST", "create-demo-profile", {}); + if (!assertOk(demoRes, "create-demo-profile")) fail("Failed to create demo profile"); + const demo = demoRes.json(); + + // Login + const loginRes = client.login(demo.email, demo.password); + if (!assertOk(loginRes, "login")) fail("Login failed"); + + sleep(0.5); + + // Get team + const teamsRes = client.getTeams(); + if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed"); + const teamId = teamsRes.body[0].id; + + // Create project + const projRes = client.createProject(teamId, `Media Project ${uuidv4().substring(0, 8)}`); + if (!assertOk(projRes, "create-project")) fail("create-project failed"); + const projectId = projRes.body.id; + + // Create file + const fileRes = client.createFile(projectId, `Media File ${uuidv4().substring(0, 8)}`); + if (!assertOk(fileRes, "create-file")) fail("create-file failed"); + const fileId = fileRes.body.id; + + sleep(0.5); + + // Upload SVG (direct — small file) + const svgRes = client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"); + if (!assertOk(svgRes, "upload SVG")) { + console.error(`VU ${__VU}: SVG upload failed`); + } else { + console.log(`VU ${__VU}: Uploaded SVG (${imageSvg.byteLength} B, direct)`); + } + + sleep(0.5); + + // Upload PNG (direct — small file) + const pngRes = client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"); + if (!assertOk(pngRes, "upload PNG")) { + console.error(`VU ${__VU}: PNG upload failed`); + } else { + console.log(`VU ${__VU}: Uploaded PNG (${imagePng.byteLength} B, direct)`); + } + + sleep(0.5); + + // Upload JPG (chunked — 305 KB > 50 KB threshold) + const jpgRes = client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"); + if (!assertOk(jpgRes, "upload JPG")) { + console.error(`VU ${__VU}: JPG upload failed`); + } else { + console.log(`VU ${__VU}: Uploaded JPG (${imageJpg.byteLength} B, chunked)`); + } + + console.log(`VU ${__VU}: Media upload test complete`); +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown(data) { + console.log("Media upload test complete."); +} diff --git a/performance/scripts/workspace-edit.js b/performance/scripts/workspace-edit.js new file mode 100644 index 0000000000..9637014338 --- /dev/null +++ b/performance/scripts/workspace-edit.js @@ -0,0 +1,212 @@ +// Workspace Edit Performance Test (Write-heavy) +// +// Simulates a user editing a file — repeatedly fetching the file (to get +// the latest revn) and submitting changes. Each VU edits its own file +// independently, so there are no concurrency conflicts. +// +// Flow: +// 1. Register (demo profile) +// 2. Login +// 3. Create project → create file +// 4. Loop: get-file → update-file (add a shape) → sleep +// +// Usage: +// k6 run scripts/workspace-edit.js +// k6 run --vus 10 --iterations 20 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 || "10"); + +export const options = { + scenarios: { + workspace_edit: { + executor: "per-vu-iterations", + vus: 1, + iterations: 1, + maxDuration: "5m", + }, + }, + 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 a file per VU to edit +// --------------------------------------------------------------------------- + +export function setup() { + console.log(`Penpot Workspace Edit Test`); + console.log(` Base URL: ${BASE_URL}`); + console.log(` Edit iterations: ${EDIT_ITERATIONS}`); + console.log(``); + + const client = createClient(BASE_URL); + + // Create demo profile + const demoRes = client.rpc("POST", "create-demo-profile", {}); + if (demoRes.status !== 200) fail("Failed to create demo profile"); + const demo = demoRes.json(); + + // Login + const loginRes = client.login(demo.email, demo.password); + if (loginRes.status !== 200) fail("Login failed"); + + // Get default team + const teamsRes = client.getTeams(); + if (teamsRes.status !== 200) fail("get-teams failed"); + const teamId = teamsRes.body[0].id; + + // Create project for edit files + const projRes = client.createProject(teamId, "WS Edit Project"); + if (projRes.status !== 200) fail("create-project failed"); + const projectId = projRes.body.id; + + console.log(` Project: ${projectId}`); + + return { + baseUrl: BASE_URL, + projectId, + profileId: loginRes.body.id, + email: demo.email, + password: demo.password, + }; +} + +// --------------------------------------------------------------------------- +// Main VU Function — each VU creates its own file and edits it +// --------------------------------------------------------------------------- + +export default function (data) { + const client = createClient(data.baseUrl); + + // Login with the profile that owns the project + const loginRes = client.login(data.email, data.password); + if (!assertOk(loginRes, "login")) fail("login failed"); + + sleep(0.5); + + // Create a file for this VU + const fileRes = client.createFile(data.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"); + let pageId = getFileRes.body.data.pages[0]; + let revn = getFileRes.body.revn; + let vern = getFileRes.body.vern; + + 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")) { + console.warn(`VU ${__VU}: get-file failed on iteration ${i}, skipping`); + continue; + } + revn = refreshRes.body.revn; + vern = refreshRes.body.vern; + + 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) { + // ok + } else { + // 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."); +} diff --git a/performance/scripts/workspace-open.js b/performance/scripts/workspace-open.js new file mode 100644 index 0000000000..123a973ed6 --- /dev/null +++ b/performance/scripts/workspace-open.js @@ -0,0 +1,218 @@ +// Workspace Open Performance Test (Read-heavy) +// +// Simulates a user opening a file in the workspace editor. +// This is the most common read-heavy operation — loading a file and its +// dependencies (libraries, thumbnails). +// +// Flow: +// 1. Register (demo profile) +// 2. Login +// 3. Get teams → default team +// 4. Create project → create file (setup data) +// 5. Update file with a shape (so the file has data) +// 6. Loop: get-file → get-file-libraries → get-file-object-thumbnails +// → get-file-data-for-thumbnail +// +// Usage: +// k6 run scripts/workspace-open.js +// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/workspace-open.js +// k6 run --vus 10 --iterations 5 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 = { + scenarios: { + workspace_open: { + executor: "per-vu-iterations", + vus: 1, + iterations: 1, + maxDuration: "2m", + }, + }, + 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 a file with data that we'll open repeatedly +// --------------------------------------------------------------------------- + +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); + + // Small random delay to avoid demo profile creation race with parallel scripts + sleep(Math.random() * 3); + + // Create demo profile + const demoRes = client.rpc("POST", "create-demo-profile", {}); + if (demoRes.status !== 200) { + fail("Failed to create demo profile — is demo-users flag enabled?"); + } + const demoBody = demoRes.json(); + console.log(` Created demo profile: ${demoBody.email}`); + + // Login + const loginRes = client.login(demoBody.email, demoBody.password); + if (loginRes.status !== 200) fail("Login failed"); + + // Get default team + const teamsRes = client.getTeams(); + if (teamsRes.status !== 200) fail("get-teams failed"); + const teamId = teamsRes.body[0].id; + + // Create project + const projRes = client.createProject(teamId, "WS Open Project"); + if (projRes.status !== 200) fail("create-project failed"); + const projectId = projRes.body.id; + + // Create file + const fileRes = client.createFile(projectId, "WS Open File"); + if (fileRes.status !== 200) fail("create-file failed"); + const fileId = fileRes.body.id; + + // Get file to read page-id, revn, vern + const getFileRes = client.getFile(fileId); + if (getFileRes.status !== 200) fail("get-file failed"); + const fileData = getFileRes.body; + const pageId = fileData.data.pages[0]; + + // Add a shape so the file has meaningful data + const shapeId = uuidv4(); + const x = 50; + const y = 50; + const w = 300; + const h = 200; + const changes = [{ + type: "add-obj", + pageId: 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, + }, + }]; + const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes); + if (updateRes.status !== 200) fail("update-file (seed shape) failed"); + + console.log(` File ready: ${fileId} (page: ${pageId})`); + + return { + baseUrl: BASE_URL, + fileId, + projectId, + teamId, + profileId: loginRes.body.id, + email: demoBody.email, + password: demoBody.password, + }; +} + +// --------------------------------------------------------------------------- +// Main VU Function +// --------------------------------------------------------------------------- + +export default function (data) { + const client = createClient(data.baseUrl); + + // Small random delay to spread out concurrent VU logins + sleep(Math.random() * 2); + + // Login with the same profile that owns the file + const loginRes = client.login(data.email, data.password); + if (!assertOk(loginRes, "login")) fail("login failed"); + + sleep(0.5); + + for (let i = 0; i < OPEN_ITERATIONS; i++) { + // 1. Get file + const getFileRes = client.getFile(data.fileId); + if (!assertOk(getFileRes, "get-file")) fail("get-file failed"); + + sleep(0.3); + + // 2. Get file libraries + const libsRes = client.getFileLibraries(data.fileId); + if (!assertOk(libsRes, "get-file-libraries")) fail("get-file-libraries failed"); + + sleep(0.2); + + // 3. Get object thumbnails + const thumbsRes = client.getFileObjectThumbnails(data.fileId); + if (!assertOk(thumbsRes, "get-file-object-thumbnails")) fail("get-file-object-thumbnails failed"); + + sleep(0.2); + + // 4. Get file data for thumbnail + const thumbDataRes = client.getFileDataForThumbnail(data.fileId); + if (!assertOk(thumbDataRes, "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed"); + + sleep(1); + } + + console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} workspace open iterations`); +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown(data) { + console.log("Workspace open test complete."); +} diff --git a/plans/2026-06-12-backend-performance-test.md b/plans/2026-06-12-backend-performance-test.md index e2e63f6baf..ad12cfaf3b 100644 --- a/plans/2026-06-12-backend-performance-test.md +++ b/plans/2026-06-12-backend-performance-test.md @@ -17,88 +17,81 @@ ### Completed (2026-06-12) -Phase 1 is done. Phase 2 has the first flow (lifecycle) implemented and validated. +Phase 1 done. Phase 2 done (all core flows). Phase 3 done (orchestrator). Phase 4 and 5 remain. **What was built:** ``` performance/ -├── run.sh # Bash runner script (smoke, lifecycle, clean, help) +├── run.sh # Bash runner — all commands + orchestrator ├── README.md # Usage docs, configuration, architecture notes ├── lib/ -│ └── penpot-client.js # 388 lines — shared k6 HTTP client module +│ └── penpot-client.js # ~590 lines — shared k6 HTTP client module ├── scripts/ -│ └── lifecycle.js # 398 lines — full user lifecycle test -├── fixtures/ -│ ├── test-small.png # 97 B -│ ├── test-medium.png # 30 KB -│ └── test-large.png # 120 KB +│ ├── 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) +│ ├── media-upload.js # Image uploads: SVG/PNG direct, JPG chunked +│ └── font-upload.js # Font uploads: TTF+OTF chunked, create-font-variant ├── results/ # k6 JSON output (gitignored) └── baselines/ # for regression baselines ``` -**What the lifecycle test covers (all passing):** +Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in `performance/`). -| Step | RPC Command | Method | Observed Latency | -|------|------------|--------|-----------------| -| 1. Register | `create-demo-profile` | POST | ~136ms | -| 2. Login | `login-with-password` | POST | ~136ms | -| 3. Get profile | `get-profile` | GET | ~7ms | -| 4. Get teams | `get-teams` | GET | ~6ms | -| 5. Create project | `create-project` | POST | ~9ms | -| 6. Create file | `create-file` | POST | ~22ms | -| 7. Get file | `get-file` | GET | ~12ms | -| 8. Update file | `update-file` | POST | ~25ms | -| 9. Upload image | `upload-file-media-object` | POST (multipart) | ~7ms | -| 10. Delete file | `delete-file` | POST | ~12ms | -| 11. Delete project | `delete-project` | POST | ~5ms | -| 12. Logout | `logout` | POST | ~4ms | +**All flows validated (smoke test, 1 VU, 1 iteration each):** -Smoke test result: **10/10 checks pass, 0% failure rate, ~10s total iteration**. +| Script | Checks | HTTP Requests | Failure Rate | Duration | +|--------|--------|---------------|-------------|----------| +| `lifecycle.js` | 11/11 | 22 | 0% | ~12s | +| `workspace-open.js` | 5/5 per iter (×3) | 12 | 0% | ~4s | +| `workspace-edit.js` | 5/5 per iter (×2) | 11 | 0% | ~4s | +| `media-upload.js` | 8/8 | 17 | 0% | ~3s | +| `font-upload.js` | 11/11 | 12 | 0% | ~3s | -**Key discoveries during implementation:** +**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel (10 VUs total), all checks pass, 0% failure rate. -1. **JSON transport works.** The backend accepts `Content-Type: application/json` for POST bodies (kebab-case keys auto-converted) and returns `application/json` responses (camelCase keys) via `Accept: application/json` or `_fmt=json` query param. No Transit encoder needed in k6. +**Key discoveries (cumulative):** -2. **`create-file` `features` param.** Sending `features: []` (empty array) causes a 400 validation error. Omit the field entirely — it's optional in the schema (`backend/src/app/rpc/commands/files_create.clj`). +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. -3. **`update-file` `changes` payload — shape schema is strict.** The `add-obj` change requires a fully valid shape object. Minimum required fields beyond the basics: - - `selrect`: `{x, y, width, height, x1, y1, x2, y2}` - - `points`: array of 4 `{x, y}` corner points - - `transform` / `transform-inverse`: identity matrix `{a:1, b:0, c:0, d:1, e:0, f:0}` - - `parent-id` and `frame-id` inside the `obj` - - `frame-id` at the top level of the change object itself (required, not optional) - - Schema defined in `common/src/app/common/files/changes.cljc` (line 189) and `common/src/app/common/types/shape.cljc` (line 165). +2. **`create-file` `features` param.** Sending `features: []` causes 400. Omit entirely — it's optional (`backend/src/app/rpc/commands/files_create.clj`). -4. **`update-file` URL convention.** The frontend sends `id` both as a query param and in the POST body. The URL is `POST /api/main/methods/update-file?id=`. +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`. -5. **Two registration modes work:** - - `demo` mode: `create-demo-profile` — fast, requires `demo-users` feature flag. - - `register` mode: `prepare-register-profile` + `register-profile` — two-step flow, works without flags. +4. **`update-file` URL convention.** `POST /api/main/methods/update-file?id=` — `id` in both query string and body. -6. **k6 installed at** `/home/penpot/.local/bin/k6` (v0.56.0). Not in default PATH; use `PATH="/home/penpot/.local/bin:$PATH"` or set `K6` env var. +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.** `create-demo-profile` uses timestamp-based emails. Parallel calls within the same ms get the same email. Mitigated with `sleep(Math.random() * 2)` before profile creation in parallel scenarios. + +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`. ### Remaining Work | Phase | Status | Next Actions | |-------|--------|-------------| | Phase 1 – Discovery & Tooling | **Done** | — | -| Phase 2 – Core HTTP Flows | **Partial** — lifecycle.js done | Write `workspace-open.js`, `workspace-edit.js`, `media-upload.js`, `font-upload.js`, `viewer.js`, `export.js` | -| Phase 2 – Data seeding | **Not started** | Create `seed-data.js` for 100+ user pool | -| Phase 3 – Scenarios | **Not started** | Define multi-scenario k6 config mixing flows | +| Phase 2 – Core HTTP Flows | **Done** | All 5 flows implemented + orchestrator | +| Phase 2 – Data seeding | **Not needed** | User pool created in k6 `setup()` — no external script needed | +| Phase 3 – Scenarios | **Done** | `./run.sh all` runs all flows in parallel | | Phase 4 – Advanced update-file | **Not started** | File size tiers, concurrent editing matrix | | Phase 5 – CI & Reporting | **Not started** | Grafana dashboards, regression guard | ### Immediate Next Steps -1. Write `workspace-open.js` — read-heavy flow (get-file, get-file-libraries, thumbnails). -2. Write `workspace-edit.js` — write-heavy loop (get-file + update-file per VU, independent files). -3. Write `media-upload.js` — direct + chunked upload flows. -4. Write `font-upload.js` — chunked upload + create-font-variant. -5. Write `viewer.js` — get-view-only-bundle + comments. -6. Create `seed-data.js` — pre-seed 100+ users/teams/files for multi-VU runs. -7. Define scenario mix in `run.sh` (multi-scenario support with ramping VUs). -8. Add `--scenario` flag to `run.sh` to select individual flows or the full mix. +1. ~~Create `seed-data.js`~~ — Not needed. Use k6 `setup()` to create a user pool before VUs start. Each VU picks a pre-existing user from the pool (`data.users[__VU - 1]`). Setup is sequential (~140ms/user) and excluded from metrics. Self-contained, no external tooling. +2. Add `--scenario` flag to `run.sh` to select individual flows by name from the orchestrator. +3. Write `viewer.js` — `get-view-only-bundle` + `get-comment-threads` (deferred per user request). +4. Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes). +5. Phase 4: Concurrent editing test (2–3 VUs per file, measure conflict rate). +6. Phase 5: Grafana dashboard panels (p95 latency by RPC, error rate, JVM, DB pool). --- @@ -468,27 +461,27 @@ Run `workspace-edit.js` against each tier separately and plot: - [ ] `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) -- [ ] A chunked upload (`create-upload-session` → `upload-chunk` → `assemble-file-media-object`) succeeds. -- [ ] A `create-font-variant` request with chunked uploads succeeds. +- [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 (388 lines, JSON transport, cookie auth, session headers, tagged metrics) +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 (`performance/scripts/seed-data.js`) that creates 100+ users, teams, projects, and files of varying sizes. -5. ~~Write the first k6 script: `lifecycle.js`~~ ✅ Done (398 lines, 12-step CRUD flow, all checks passing) -6. ~~Run a 1-VU smoke test against local devenv and commit the baseline results.~~ ✅ Done (10/10 checks, 0% failure, ~10s iteration) -7. Write `workspace-open.js` and `workspace-edit.js`. -8. Write `media-upload.js` and `font-upload.js`. -9. Define the `1000-VU` scenario mix in `options.js` (shared scenario config). +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 1 complete, Phase 2 partially complete (lifecycle.js done). See "Current Progress" section above. +**Status:** Phase 1–3 complete (all core flows + orchestrator). Phase 4–5 remain. See "Current Progress" above.