mirror of
https://github.com/penpot/penpot.git
synced 2026-06-27 17:52:05 +00:00
🚧 Add more workflows
This commit is contained in:
parent
077a643b9f
commit
93c412f9a5
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 97 B |
@ -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,
|
||||
|
||||
@ -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") <command> [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
|
||||
|
||||
216
performance/scripts/font-upload.js
Normal file
216
performance/scripts/font-upload.js
Normal file
@ -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.");
|
||||
}
|
||||
@ -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)`);
|
||||
|
||||
177
performance/scripts/media-upload.js
Normal file
177
performance/scripts/media-upload.js
Normal file
@ -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.");
|
||||
}
|
||||
212
performance/scripts/workspace-edit.js
Normal file
212
performance/scripts/workspace-edit.js
Normal file
@ -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.");
|
||||
}
|
||||
218
performance/scripts/workspace-open.js
Normal file
218
performance/scripts/workspace-open.js
Normal file
@ -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.");
|
||||
}
|
||||
@ -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=<uuid>`.
|
||||
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=<uuid>` — `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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user