mirror of
https://github.com/penpot/penpot.git
synced 2026-06-20 06:12:04 +00:00
🚧 Add the ability to scale users to 1000 or more
This commit is contained in:
parent
c156387a9f
commit
25a5ac8513
@ -10,6 +10,7 @@
|
||||
[app.auth :refer [derive-password]]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
@ -34,8 +35,8 @@
|
||||
:code :demo-users-not-allowed
|
||||
:hint "Demo users are disabled by config."))
|
||||
|
||||
(let [sem (System/currentTimeMillis)
|
||||
email (str "demo-" sem ".demo@example.com")
|
||||
(let [sem (uuid/next)
|
||||
email (str "demo-" sem "@demo.example.com")
|
||||
fullname (str "Demo User " sem)
|
||||
|
||||
password (-> (bn/random-bytes 16)
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
// 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).
|
||||
// Tests the font upload flow: chunked upload of TTF + OTF files followed by
|
||||
// creating a font variant. Exercises storage pipeline and font processing.
|
||||
//
|
||||
// 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
|
||||
// setup() creates N demo profiles.
|
||||
// Each VU picks its user, uploads fonts, and creates a variant.
|
||||
//
|
||||
// Usage:
|
||||
// k6 run scripts/font-upload.js
|
||||
// k6 run --vus 3 --iterations 2 scripts/font-upload.js
|
||||
// k6 run --vus 50 --iterations 5 scripts/font-upload.js
|
||||
|
||||
import { check, sleep, fail } from "k6";
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
@ -42,7 +39,7 @@ export const options = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Data — load font fixtures from backend test files
|
||||
// Test Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fontTtf = open("../../backend/test/backend_tests/test_files/font-1.ttf", "b");
|
||||
@ -75,22 +72,29 @@ function assertOk(res, label) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// Setup — create user pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setup() {
|
||||
const vuCount = (options.scenarios.font_upload && options.scenarios.font_upload.vus) || __ENV.K6_VUS || 1;
|
||||
|
||||
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(` VUs: ${vuCount}`);
|
||||
console.log(``);
|
||||
|
||||
const client = createClient(BASE_URL);
|
||||
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
const res = client.getProfile();
|
||||
if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
const users = [];
|
||||
for (let i = 0; i < vuCount; i++) {
|
||||
const res = client.rpc("POST", "create-demo-profile", {});
|
||||
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
|
||||
users.push(res.json());
|
||||
}
|
||||
console.log(` Created ${users.length} demo profiles`);
|
||||
|
||||
return { baseUrl: BASE_URL };
|
||||
return { baseUrl: BASE_URL, users };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -100,111 +104,57 @@ export function setup() {
|
||||
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();
|
||||
// Pick user from pool
|
||||
const user = data.users[__VU - 1];
|
||||
if (!user) fail(`No user for VU ${__VU}`);
|
||||
|
||||
// Login
|
||||
const loginRes = client.login(demo.email, demo.password);
|
||||
if (!assertOk(loginRes, "login")) fail("Login failed");
|
||||
const teamId = loginRes.body.defaultTeamId;
|
||||
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
|
||||
const teamId = client.getTeams().body[0].id;
|
||||
|
||||
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)}`;
|
||||
const chunkSize = 50 * 1024; // 50 KB
|
||||
|
||||
console.log(`VU ${__VU}: Uploading font "${fontFamily}" (font-id: ${fontId})`);
|
||||
|
||||
// Create upload session for TTF
|
||||
const chunkSize = 50 * 1024; // 50 KB — matches client default
|
||||
// Upload TTF via chunked upload
|
||||
const ttfChunks = Math.ceil(fontTtf.byteLength / chunkSize);
|
||||
const ttfSessionRes = client.createUploadSession(ttfChunks);
|
||||
if (!assertOk(ttfSessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed");
|
||||
const ttfSessionId = ttfSessionRes.sessionId;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const chunk = fontTtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontTtf.byteLength));
|
||||
if (!assertOk(client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"), `upload-chunk ttf ${i}`)) fail("ttf chunk failed");
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
console.log(`VU ${__VU}: Uploaded TTF in ${ttfChunks} chunks`);
|
||||
|
||||
// Upload OTF via chunked upload (separate session)
|
||||
// Upload OTF via chunked upload
|
||||
const otfChunks = Math.ceil(fontOtf.byteLength / chunkSize);
|
||||
|
||||
const otfSessionRes = client.createUploadSession(otfChunks);
|
||||
if (!assertOk(otfSessionRes, "create-upload-session (otf)")) fail("create-upload-session (otf) failed");
|
||||
const otfSessionId = otfSessionRes.sessionId;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const chunk = fontOtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontOtf.byteLength));
|
||||
if (!assertOk(client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"), `upload-chunk otf ${i}`)) fail("otf chunk failed");
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
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,
|
||||
if (!assertOk(client.rpc("POST", "create-font-variant", {
|
||||
"team-id": teamId,
|
||||
"font-id": fontId,
|
||||
"font-family": fontFamily,
|
||||
"font-weight": 400,
|
||||
"font-style": "normal",
|
||||
uploads: {
|
||||
"font/ttf": ttfSessionId,
|
||||
"font/otf": otfSessionId,
|
||||
},
|
||||
});
|
||||
uploads: { "font/ttf": ttfSessionId, "font/otf": otfSessionId },
|
||||
}), "create-font-variant")) fail("create-font-variant failed");
|
||||
|
||||
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`);
|
||||
console.log(`VU ${__VU}: Font "${fontFamily}" created`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -3,23 +3,25 @@
|
||||
// Simulates a realistic user lifecycle from registration through CRUD operations.
|
||||
// Each VU performs the full flow independently, creating its own artifacts.
|
||||
//
|
||||
// setup() creates a user pool (one demo profile per VU) before measurements begin.
|
||||
// Each VU picks its assigned user to login — no profile creation during the test.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Register (via demo profile or prepare+register)
|
||||
// 2. Login
|
||||
// 3. Get profile & teams
|
||||
// 4. Create project
|
||||
// 5. Create file
|
||||
// 6. Get file
|
||||
// 7. Update file (add a shape)
|
||||
// 8. Upload image to file
|
||||
// 9. Delete file
|
||||
// 10. Delete project
|
||||
// 11. Delete team
|
||||
// 1. Login (with pre-existing user from pool)
|
||||
// 2. Get profile & teams
|
||||
// 3. Create project
|
||||
// 4. Create file
|
||||
// 5. Get file
|
||||
// 6. Update file (add a shape)
|
||||
// 7. Upload images (direct + chunked)
|
||||
// 8. Delete file
|
||||
// 9. Delete project
|
||||
// 10. Logout
|
||||
//
|
||||
// Usage:
|
||||
// k6 run scripts/lifecycle.js
|
||||
// k6 run --vus 100 --iterations 100 scripts/lifecycle.js
|
||||
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/lifecycle.js
|
||||
// k6 run --env PENPOT_REGISTER_MODE=register scripts/lifecycle.js
|
||||
|
||||
import { check, sleep, fail } from "k6";
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
@ -30,9 +32,6 @@ import { createClient } from "../lib/penpot-client.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
|
||||
// "demo" = use create-demo-profile (requires demo-users flag)
|
||||
// "register" = use prepare-register-profile + register-profile
|
||||
const REGISTER_MODE = __ENV.PENPOT_REGISTER_MODE || "demo";
|
||||
|
||||
// k6 options — smoke test defaults (1 VU, 1 iteration)
|
||||
export const options = {
|
||||
@ -61,14 +60,10 @@ export const options = {
|
||||
// Test Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Load test PNG fixtures — small uses direct upload, large uses chunked upload
|
||||
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.
|
||||
// All object properties use camelCase — the backend's JSON parser
|
||||
// (json/read-kebab-key) converts camelCase to kebab-case keywords automatically.
|
||||
function makeAddRectChange(pageId) {
|
||||
const shapeId = uuidv4();
|
||||
const x = 100;
|
||||
@ -80,42 +75,22 @@ function makeAddRectChange(pageId) {
|
||||
type: "add-obj",
|
||||
pageId: pageId,
|
||||
id: shapeId,
|
||||
frameId: pageId, // required: the frame this shape belongs to
|
||||
parentId: pageId, // root frame is the page itself
|
||||
frameId: pageId,
|
||||
parentId: pageId,
|
||||
obj: {
|
||||
id: shapeId,
|
||||
type: "rect",
|
||||
name: "Perf Test Rect",
|
||||
x: x,
|
||||
y: y,
|
||||
width: w,
|
||||
height: h,
|
||||
fillColor: "#ff0000",
|
||||
fillOpacity: 1,
|
||||
rotation: 0,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
// Required base attrs
|
||||
selrect: {
|
||||
x: x,
|
||||
y: y,
|
||||
width: w,
|
||||
height: h,
|
||||
x1: x,
|
||||
y1: y,
|
||||
x2: x + w,
|
||||
y2: y + h,
|
||||
},
|
||||
x: x, y: y, width: w, height: h,
|
||||
fillColor: "#ff0000", fillOpacity: 1,
|
||||
rotation: 0, hidden: false, locked: false,
|
||||
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
|
||||
points: [
|
||||
{ x: x, y: y },
|
||||
{ x: x + w, y: y },
|
||||
{ x: x + w, y: y + h },
|
||||
{ x: x, y: y + h },
|
||||
{ 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,
|
||||
parentId: pageId, frameId: pageId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -129,7 +104,6 @@ function assertOk(res, label) {
|
||||
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
if (!ok) {
|
||||
// Try to get the response body for debugging
|
||||
let bodyStr = "";
|
||||
try {
|
||||
if (res.raw && res.raw.body) {
|
||||
@ -142,32 +116,43 @@ function assertOk(res, label) {
|
||||
} catch (e) {
|
||||
bodyStr = "(could not read body)";
|
||||
}
|
||||
console.error(
|
||||
`FAIL: ${label} — status=${res.status} body=${bodyStr}`
|
||||
);
|
||||
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup — runs once before VUs start
|
||||
// Setup — create user pool before VUs start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setup() {
|
||||
// Resolve VU count from options or CLI --vus flag
|
||||
const vuCount = (options.scenarios.lifecycle && options.scenarios.lifecycle.vus) || __ENV.K6_VUS || 1;
|
||||
|
||||
console.log(`Penpot Lifecycle Test`);
|
||||
console.log(` Base URL: ${BASE_URL}`);
|
||||
console.log(` Register mode: ${REGISTER_MODE}`);
|
||||
console.log(` Base URL: ${BASE_URL}`);
|
||||
console.log(` VUs: ${vuCount}`);
|
||||
console.log(` Creating ${vuCount} demo profiles...`);
|
||||
console.log(``);
|
||||
|
||||
// Verify the backend is reachable
|
||||
const client = createClient(BASE_URL);
|
||||
const res = client.getProfile();
|
||||
// We expect 401/403 (not logged in) — anything else means the backend is down
|
||||
if (res.status === 0) {
|
||||
fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
// Verify backend is reachable
|
||||
const pingRes = client.getProfile();
|
||||
if (pingRes.status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
// Create one demo profile per VU
|
||||
const users = [];
|
||||
for (let i = 0; i < vuCount; i++) {
|
||||
const res = client.rpc("POST", "create-demo-profile", {});
|
||||
if (res.status !== 200) {
|
||||
fail(`Failed to create demo profile ${i + 1}/${vuCount}: status=${res.status}`);
|
||||
}
|
||||
users.push(res.json());
|
||||
}
|
||||
|
||||
return { baseUrl: BASE_URL, registerMode: REGISTER_MODE };
|
||||
console.log(` Created ${users.length} demo profiles`);
|
||||
return { baseUrl: BASE_URL, users };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -177,243 +162,105 @@ 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;
|
||||
|
||||
if (data.registerMode === "demo") {
|
||||
// Demo mode: create-demo-profile returns email + password
|
||||
const demoRes = client.rpc("POST", "create-demo-profile", {});
|
||||
if (!assertOk(demoRes, "create-demo-profile")) {
|
||||
fail("Failed to create demo profile");
|
||||
}
|
||||
const demoBody = demoRes.json();
|
||||
email = demoBody.email;
|
||||
password = demoBody.password;
|
||||
console.log(`VU ${__VU}: Created demo profile: ${email}`);
|
||||
} else {
|
||||
// Register mode: prepare + register
|
||||
email = `perf-${uuidv4()}@test.local`;
|
||||
password = "PerfTest1234!";
|
||||
const fullname = `Perf User ${__VU}`;
|
||||
|
||||
const prepareRes = client.rpc("POST", "prepare-register-profile", {
|
||||
fullname,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (!assertOk(prepareRes, "prepare-register-profile")) {
|
||||
fail("Failed to prepare registration");
|
||||
}
|
||||
const prepareBody = prepareRes.json();
|
||||
const token = prepareBody.token;
|
||||
|
||||
const registerRes = client.rpc("POST", "register-profile", {
|
||||
token,
|
||||
});
|
||||
if (!assertOk(registerRes, "register-profile")) {
|
||||
fail("Failed to register profile");
|
||||
}
|
||||
console.log(`VU ${__VU}: Registered profile: ${email}`);
|
||||
// Pick user from pool
|
||||
const user = data.users[__VU - 1];
|
||||
if (!user) {
|
||||
fail(`No user for VU ${__VU} (pool size: ${data.users.length})`);
|
||||
}
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 1: Login ----
|
||||
const loginRes = client.login(email, password);
|
||||
if (!assertOk(loginRes, "login-with-password")) {
|
||||
fail("Login failed");
|
||||
}
|
||||
const loginRes = client.login(user.email, user.password);
|
||||
if (!assertOk(loginRes, "login-with-password")) fail("Login failed");
|
||||
const profile = loginRes.body;
|
||||
const profileId = profile.id;
|
||||
console.log(`VU ${__VU}: Logged in, profile-id=${profileId}`);
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 2: Get profile ----
|
||||
const profileRes = client.getProfile();
|
||||
if (!assertOk(profileRes, "get-profile")) {
|
||||
fail("get-profile failed");
|
||||
}
|
||||
if (!assertOk(client.getProfile(), "get-profile")) fail("get-profile failed");
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ---- Step 3: Get teams ----
|
||||
const teamsRes = client.getTeams();
|
||||
if (!assertOk(teamsRes, "get-teams")) {
|
||||
fail("get-teams failed");
|
||||
}
|
||||
const teams = teamsRes.body;
|
||||
// teams is an array; the user has a default team from registration
|
||||
const defaultTeamId = Array.isArray(teams) && teams.length > 0
|
||||
? teams[0].id
|
||||
: null;
|
||||
|
||||
if (!defaultTeamId) {
|
||||
fail("No default team found after registration");
|
||||
}
|
||||
if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed");
|
||||
const defaultTeamId = teamsRes.body[0].id;
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ---- Step 4: Create a project ----
|
||||
const projectName = `Perf Project ${uuidv4().substring(0, 8)}`;
|
||||
const projectRes = client.createProject(defaultTeamId, projectName);
|
||||
if (!assertOk(projectRes, "create-project")) {
|
||||
fail("create-project failed");
|
||||
}
|
||||
const project = projectRes.body;
|
||||
const projectId = project.id;
|
||||
console.log(`VU ${__VU}: Created project: ${projectId}`);
|
||||
const projectRes = client.createProject(defaultTeamId, `Perf Project ${uuidv4().substring(0, 8)}`);
|
||||
if (!assertOk(projectRes, "create-project")) fail("create-project failed");
|
||||
const projectId = projectRes.body.id;
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 5: Create a file ----
|
||||
const fileName = `Perf File ${uuidv4().substring(0, 8)}`;
|
||||
const fileRes = client.createFile(projectId, fileName);
|
||||
if (!assertOk(fileRes, "create-file")) {
|
||||
fail("create-file failed");
|
||||
}
|
||||
const file = fileRes.body;
|
||||
const fileId = file.id;
|
||||
console.log(`VU ${__VU}: Created file: ${fileId}`);
|
||||
const fileRes = client.createFile(projectId, `Perf File ${uuidv4().substring(0, 8)}`);
|
||||
if (!assertOk(fileRes, "create-file")) fail("create-file failed");
|
||||
const fileId = fileRes.body.id;
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 6: Get the file (to read revn, vern, page-id) ----
|
||||
// ---- Step 6: Get the file ----
|
||||
const getFileRes = client.getFile(fileId);
|
||||
if (!assertOk(getFileRes, "get-file")) {
|
||||
fail("get-file failed");
|
||||
}
|
||||
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
|
||||
const fileData = getFileRes.body;
|
||||
const revn = fileData.revn;
|
||||
const vern = fileData.vern;
|
||||
|
||||
// Extract the first page-id from the file data
|
||||
// fileData.data.pages is an array of page UUIDs
|
||||
// fileData.data.pages-index is a map of page-id -> page objects
|
||||
let pageId = null;
|
||||
if (fileData.data && fileData.data.pages && fileData.data.pages.length > 0) {
|
||||
pageId = fileData.data.pages[0];
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
console.warn(`VU ${__VU}: Could not find page-id in file data, skipping update-file`);
|
||||
}
|
||||
const pageId = fileData.data.pages[0];
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 7: Update file (add a rectangle shape) ----
|
||||
let updateOk = false;
|
||||
// ---- Step 7: Update file (add a shape) ----
|
||||
if (pageId) {
|
||||
const changes = [makeAddRectChange(pageId)];
|
||||
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
|
||||
const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes);
|
||||
|
||||
if (updateRes.status === 200) {
|
||||
updateOk = true;
|
||||
console.log(`VU ${__VU}: Updated file successfully`);
|
||||
} else {
|
||||
// Check for revn conflict — retry once
|
||||
if (updateRes.status !== 200) {
|
||||
// Retry once on revn conflict
|
||||
const body = updateRes.body;
|
||||
console.error(`VU ${__VU}: update-file failed: status=${updateRes.status} body=${JSON.stringify(body).substring(0, 500)}`);
|
||||
const isRevnConflict =
|
||||
body && (body.code === "revn-conflict" || body.type === "revn-conflict");
|
||||
|
||||
if (isRevnConflict) {
|
||||
console.log(`VU ${__VU}: Revn conflict, retrying...`);
|
||||
// Fetch latest file state
|
||||
const retryFileRes = client.getFile(fileId);
|
||||
if (assertOk(retryFileRes, "get-file (retry)")) {
|
||||
const retryData = retryFileRes.body;
|
||||
const retryRes = client.updateFile(
|
||||
fileId,
|
||||
retryData.revn,
|
||||
retryData.vern,
|
||||
client.sessionId,
|
||||
changes
|
||||
);
|
||||
if (assertOk(retryRes, "update-file (retry)")) {
|
||||
updateOk = true;
|
||||
console.log(`VU ${__VU}: Updated file on retry`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`VU ${__VU}: update-file failed: status=${updateRes.status} body=${JSON.stringify(body).substring(0, 300)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 8: Upload images to the file ----
|
||||
// Small image (97 B) uses direct multipart upload.
|
||||
// Large image (120 KB) uses chunked upload (create-session → upload-chunk × N → assemble).
|
||||
// ---- Step 8: Upload images ----
|
||||
if (testImageSmall && testImageSmall.byteLength > 0) {
|
||||
const uploadRes = client.uploadFileMediaObject(
|
||||
fileId,
|
||||
testImageSmall,
|
||||
"test-small.png",
|
||||
"image/png"
|
||||
assertOk(
|
||||
client.uploadFileMediaObject(fileId, testImageSmall, "sample.png", "image/png"),
|
||||
"upload (direct)"
|
||||
);
|
||||
if (assertOk(uploadRes, "upload-file-media-object (direct)")) {
|
||||
console.log(`VU ${__VU}: Uploaded small image (direct)`);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
if (testImageLarge && testImageLarge.byteLength > 0) {
|
||||
const uploadRes = client.uploadFileMediaObject(
|
||||
fileId,
|
||||
testImageLarge,
|
||||
"sample.jpg",
|
||||
"image/jpeg"
|
||||
assertOk(
|
||||
client.uploadFileMediaObject(fileId, testImageLarge, "sample.jpg", "image/jpeg"),
|
||||
"upload (chunked)"
|
||||
);
|
||||
if (assertOk(uploadRes, "upload-file-media-object (chunked)")) {
|
||||
console.log(`VU ${__VU}: Uploaded large image (chunked)`);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ---- Step 9: Delete the file ----
|
||||
const deleteFileRes = client.deleteFile(fileId);
|
||||
if (assertOk(deleteFileRes, "delete-file")) {
|
||||
console.log(`VU ${__VU}: Deleted file: ${fileId}`);
|
||||
}
|
||||
|
||||
// ---- Step 9: Delete file ----
|
||||
assertOk(client.deleteFile(fileId), "delete-file");
|
||||
sleep(0.5);
|
||||
|
||||
// ---- Step 10: Delete the project ----
|
||||
const deleteProjectRes = client.deleteProject(projectId);
|
||||
if (assertOk(deleteProjectRes, "delete-project")) {
|
||||
console.log(`VU ${__VU}: Deleted project: ${projectId}`);
|
||||
}
|
||||
|
||||
// ---- Step 10: Delete project ----
|
||||
assertOk(client.deleteProject(projectId), "delete-project");
|
||||
sleep(0.5);
|
||||
|
||||
// ---- Step 11: Delete the team ----
|
||||
// Note: We only delete the team if it's NOT the default team.
|
||||
// The default team cannot be deleted (or may cause errors).
|
||||
// For this test, we skip team deletion to avoid errors.
|
||||
// In a real scenario, we'd create a separate team and delete that.
|
||||
console.log(`VU ${__VU}: Skipping team deletion (using default team)`);
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ---- Step 12: Logout ----
|
||||
const logoutRes = client.logout(profileId);
|
||||
console.log(`VU ${__VU}: Logout status: ${logoutRes.status}`);
|
||||
|
||||
console.log(`VU ${__VU}: Lifecycle complete`);
|
||||
// ---- Step 11: Logout ----
|
||||
client.logout(profileId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Teardown — runs once after all VUs finish
|
||||
// Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function teardown(data) {
|
||||
|
||||
@ -8,15 +8,9 @@
|
||||
// - 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
|
||||
// k6 run --vus 50 --iterations 5 scripts/media-upload.js
|
||||
|
||||
import { check, sleep, fail } from "k6";
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
@ -47,7 +41,7 @@ export const options = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Data — load fixtures from backend test files
|
||||
// Test Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const imageSvg = open("../../backend/test/backend_tests/test_files/sample1.svg", "b");
|
||||
@ -81,25 +75,29 @@ function assertOk(res, label) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// Setup — create user pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setup() {
|
||||
const vuCount = (options.scenarios.media_upload && options.scenarios.media_upload.vus) || __ENV.K6_VUS || 1;
|
||||
|
||||
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(` VUs: ${vuCount}`);
|
||||
console.log(``);
|
||||
|
||||
const client = createClient(BASE_URL);
|
||||
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
// Verify backend is reachable
|
||||
const res = client.getProfile();
|
||||
if (res.status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
const users = [];
|
||||
for (let i = 0; i < vuCount; i++) {
|
||||
const res = client.rpc("POST", "create-demo-profile", {});
|
||||
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
|
||||
users.push(res.json());
|
||||
}
|
||||
console.log(` Created ${users.length} demo profiles`);
|
||||
|
||||
return { baseUrl: BASE_URL };
|
||||
return { baseUrl: BASE_URL, users };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -109,63 +107,38 @@ export function setup() {
|
||||
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();
|
||||
// Pick user from pool
|
||||
const user = data.users[__VU - 1];
|
||||
if (!user) fail(`No user for VU ${__VU}`);
|
||||
|
||||
// Login
|
||||
const loginRes = client.login(demo.email, demo.password);
|
||||
if (!assertOk(loginRes, "login")) fail("Login failed");
|
||||
if (!assertOk(client.login(user.email, user.password), "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;
|
||||
const teamId = client.getTeams().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;
|
||||
// Create project + file
|
||||
const projectId = client.createProject(teamId, `Media ${uuidv4().substring(0, 8)}`).body.id;
|
||||
const fileId = client.createFile(projectId, `Media ${uuidv4().substring(0, 8)}`).body.id;
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// Upload SVG (direct — 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)`);
|
||||
}
|
||||
// Upload SVG (direct — 3.6 KB)
|
||||
assertOk(client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"), "upload SVG");
|
||||
|
||||
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)`);
|
||||
}
|
||||
// Upload PNG (direct — 5.1 KB)
|
||||
assertOk(client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"), "upload PNG");
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// Upload JPG (chunked — 305 KB > 50 KB threshold)
|
||||
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)`);
|
||||
}
|
||||
assertOk(client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"), "upload JPG");
|
||||
|
||||
console.log(`VU ${__VU}: Media upload test complete`);
|
||||
console.log(`VU ${__VU}: Media upload complete`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
// Workspace Edit Performance Test (Write-heavy)
|
||||
//
|
||||
// Simulates a user editing a file — repeatedly fetching the file (to get
|
||||
// Simulates users editing files — 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
|
||||
// setup() creates N demo profiles + 1 shared project.
|
||||
// Each VU picks its user, creates its own file, and edits it in a loop.
|
||||
//
|
||||
// Flow (per VU):
|
||||
// Login → create file → loop: get-file → update-file → sleep
|
||||
//
|
||||
// Usage:
|
||||
// k6 run scripts/workspace-edit.js
|
||||
// k6 run --vus 10 --iterations 20 scripts/workspace-edit.js
|
||||
// k6 run --vus 100 --iterations 50 scripts/workspace-edit.js
|
||||
|
||||
import { check, sleep, fail } from "k6";
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
@ -98,45 +98,39 @@ function makeAddRectChange(pageId, index) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup — create a file per VU to edit
|
||||
// Setup — create N users + shared project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setup() {
|
||||
const vuCount = (options.scenarios.workspace_edit && options.scenarios.workspace_edit.vus) || __ENV.K6_VUS || 1;
|
||||
|
||||
console.log(`Penpot Workspace Edit Test`);
|
||||
console.log(` Base URL: ${BASE_URL}`);
|
||||
console.log(` VUs: ${vuCount}`);
|
||||
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
|
||||
console.log(``);
|
||||
|
||||
const client = createClient(BASE_URL);
|
||||
|
||||
// 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();
|
||||
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
// Login
|
||||
const loginRes = client.login(demo.email, demo.password);
|
||||
if (loginRes.status !== 200) fail("Login failed");
|
||||
// Create N demo profiles
|
||||
const users = [];
|
||||
for (let i = 0; i < vuCount; i++) {
|
||||
const res = client.rpc("POST", "create-demo-profile", {});
|
||||
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
|
||||
users.push(res.json());
|
||||
}
|
||||
console.log(` Created ${users.length} demo profiles`);
|
||||
|
||||
// Get default team
|
||||
const teamsRes = client.getTeams();
|
||||
if (teamsRes.status !== 200) fail("get-teams failed");
|
||||
const teamId = teamsRes.body[0].id;
|
||||
// Login with first user to create shared project
|
||||
const loginRes = client.login(users[0].email, users[0].password);
|
||||
if (loginRes.status !== 200) fail("Login failed for project setup");
|
||||
const teamId = client.getTeams().body[0].id;
|
||||
const projectId = client.createProject(teamId, "WS Edit Project").body.id;
|
||||
console.log(` Shared project: ${projectId}`);
|
||||
|
||||
// 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,
|
||||
};
|
||||
return { baseUrl: BASE_URL, projectId, users };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -146,10 +140,12 @@ export function setup() {
|
||||
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");
|
||||
// Pick user from pool
|
||||
const user = data.users[__VU - 1];
|
||||
if (!user) fail(`No user for VU ${__VU}`);
|
||||
|
||||
// Login
|
||||
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
|
||||
sleep(0.5);
|
||||
|
||||
// Create a file for this VU
|
||||
@ -160,9 +156,7 @@ export default function (data) {
|
||||
// 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;
|
||||
const pageId = getFileRes.body.data.pages[0];
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
@ -170,12 +164,8 @@ export default function (data) {
|
||||
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;
|
||||
if (!assertOk(refreshRes, "get-file")) continue;
|
||||
const { revn, vern } = refreshRes.body;
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
@ -183,9 +173,7 @@ export default function (data) {
|
||||
const changes = [makeAddRectChange(pageId, i)];
|
||||
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
|
||||
|
||||
if (updateRes.status === 200) {
|
||||
// ok
|
||||
} else {
|
||||
if (updateRes.status !== 200) {
|
||||
// Retry once on revn conflict
|
||||
const body = updateRes.body;
|
||||
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
// Workspace Open Performance Test (Read-heavy)
|
||||
//
|
||||
// Simulates a user opening a file in the workspace editor.
|
||||
// Simulates many users opening the same file in the workspace editor.
|
||||
// This is the most common read-heavy operation — loading a file and its
|
||||
// dependencies (libraries, thumbnails).
|
||||
//
|
||||
// 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
|
||||
// setup() creates one user, one project, and one file with a shape.
|
||||
// All VUs login with the same user and read the same file concurrently.
|
||||
//
|
||||
// Flow (per VU iteration):
|
||||
// Login → get-file → get-file-libraries → get-file-object-thumbnails
|
||||
// → get-file-data-for-thumbnail
|
||||
//
|
||||
// Usage:
|
||||
// k6 run scripts/workspace-open.js
|
||||
// k6 run --vus 100 --iterations 20 scripts/workspace-open.js
|
||||
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/workspace-open.js
|
||||
// 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";
|
||||
@ -75,7 +73,7 @@ function assertOk(res, label) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup — create a file with data that we'll open repeatedly
|
||||
// Setup — create one user + one file with data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setup() {
|
||||
@ -86,127 +84,76 @@ export function setup() {
|
||||
|
||||
const client = createClient(BASE_URL);
|
||||
|
||||
// Small random delay to avoid demo profile creation race with parallel scripts
|
||||
sleep(Math.random() * 3);
|
||||
// Verify backend reachable
|
||||
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
|
||||
|
||||
// Create demo profile
|
||||
// Create one demo user
|
||||
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}`);
|
||||
if (demoRes.status !== 200) fail("Failed to create demo profile");
|
||||
const { email, password } = demoRes.json();
|
||||
|
||||
// Login
|
||||
const loginRes = client.login(demoBody.email, demoBody.password);
|
||||
const loginRes = client.login(email, 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 + file
|
||||
const teamId = client.getTeams().body[0].id;
|
||||
const projectId = client.createProject(teamId, "WS Open Project").body.id;
|
||||
const fileId = client.createFile(projectId, "WS Open File").body.id;
|
||||
|
||||
// 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;
|
||||
// Get file data and add a shape so it has meaningful content
|
||||
const fileData = client.getFile(fileId).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,
|
||||
const x = 50, y = 50, w = 300, h = 200;
|
||||
|
||||
client.updateFile(fileId, fileData.revn, fileData.vern, uuidv4(), [{
|
||||
type: "add-obj", pageId, id: shapeId, frameId: pageId, parentId: pageId,
|
||||
obj: {
|
||||
id: shapeId, type: "rect", name: "Background",
|
||||
x, y, width: w, height: h,
|
||||
fillColor: "#cccccc", fillOpacity: 1,
|
||||
rotation: 0, hidden: false, locked: false,
|
||||
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
|
||||
points: [
|
||||
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
|
||||
],
|
||||
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,
|
||||
};
|
||||
return { baseUrl: BASE_URL, email, password, fileId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main VU Function
|
||||
// Main VU Function — all VUs read the same file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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");
|
||||
// Login with shared user
|
||||
if (!assertOk(client.login(data.email, data.password), "login")) fail("login failed");
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
for (let i = 0; i < OPEN_ITERATIONS; i++) {
|
||||
// 1. Get file
|
||||
const getFileRes = client.getFile(data.fileId);
|
||||
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
|
||||
|
||||
if (!assertOk(client.getFile(data.fileId), "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");
|
||||
|
||||
if (!assertOk(client.getFileLibraries(data.fileId), "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");
|
||||
|
||||
if (!assertOk(client.getFileObjectThumbnails(data.fileId), "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");
|
||||
|
||||
if (!assertOk(client.getFileDataForThumbnail(data.fileId), "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed");
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} workspace open iterations`);
|
||||
console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} open iterations`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -39,17 +39,31 @@ performance/
|
||||
|
||||
Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in `performance/`).
|
||||
|
||||
**Backend change:** `backend/src/app/rpc/commands/demo.clj` — demo profile emails changed from timestamp-based (`demo-<millis>.demo@example.com`) to UUID-based (`demo-<uuid>@demo.example.com`). Eliminates concurrent creation collisions at the source.
|
||||
|
||||
**All scripts use `setup()` user pool:**
|
||||
|
||||
| Script | setup() creates | VU pattern |
|
||||
|--------|----------------|------------|
|
||||
| `lifecycle.js` | N users | Each VU picks `users[__VU-1]` → login → full CRUD |
|
||||
| `workspace-open.js` | 1 user + 1 file with shape | All VUs share same user + file (realistic concurrent reads) |
|
||||
| `workspace-edit.js` | N users + shared project | Each VU creates own file → edit loop |
|
||||
| `media-upload.js` | N users | Each VU creates project/file → upload 3 images |
|
||||
| `font-upload.js` | N users | Each VU uploads TTF+OTF → create-font-variant |
|
||||
|
||||
Setup is sequential (~140ms/user), excluded from k6 metrics. At 1000 VUs: ~2.3min setup, then pure measurement.
|
||||
|
||||
**All flows validated (smoke test, 1 VU, 1 iteration each):**
|
||||
|
||||
| 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 |
|
||||
| Script | Checks | Failure Rate |
|
||||
|--------|--------|-------------|
|
||||
| `lifecycle.js` | 10/10 | 0% |
|
||||
| `workspace-open.js` | 9/9 | 0% |
|
||||
| `workspace-edit.js` | 5/5 | 0% |
|
||||
| `media-upload.js` | 8/8 | 0% |
|
||||
| `font-upload.js` | 11/11 | 0% |
|
||||
|
||||
**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel (10 VUs total), all checks pass, 0% failure rate.
|
||||
**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel, 0% failure rate.
|
||||
|
||||
**Key discoveries (cumulative):**
|
||||
|
||||
@ -65,7 +79,7 @@ Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in
|
||||
|
||||
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.
|
||||
7. **Demo profile race condition — solved.** Backend now uses `uuid/next` for demo emails (no collisions). k6 scripts use `setup()` to create user pool before VUs start. Both changes together eliminate the scaling bottleneck.
|
||||
|
||||
8. **Chunked upload threshold.** The client uses 50 KB chunk size. Files ≤50 KB use direct multipart; files >50 KB use `create-upload-session` → `upload-chunk` × N → `assemble-file-media-object`.
|
||||
|
||||
@ -73,25 +87,29 @@ Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in
|
||||
|
||||
10. **MIME type validation.** The backend validates that the uploaded content MIME matches the declared MIME. `sample.jpg` must be sent as `image/jpeg`, not `image/png`.
|
||||
|
||||
11. **workspace-open uses shared user.** All VUs read the same file with the same user. Multiple demo users can't access each other's files without team sharing, so a single shared user is the correct pattern for read-heavy tests.
|
||||
|
||||
12. **Demo profile creation is slow due to argon2id.** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2. Creating 1000 demo profiles in `setup()` takes ~2–3 minutes just for password hashing. At 1000 VUs, this is the dominant cost of the setup phase. **Simplification:** Since `demo-users` is already a development-only feature, demo profiles should use a weaker password algorithm by default (e.g., bcrypt cost 4). No special parameters needed — just change `demo.clj` to call a new `derive-password-weak` function.
|
||||
|
||||
### Remaining Work
|
||||
|
||||
| Phase | Status | Next Actions |
|
||||
|-------|--------|-------------|
|
||||
| Phase 1 – Discovery & Tooling | **Done** | — |
|
||||
| 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 2 – Core HTTP Flows | **Done** | All 5 flows + orchestrator + setup() pool |
|
||||
| Phase 2 – Performance Optimization | **Not started** | Fast password hashing for demo users |
|
||||
| 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. ~~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).
|
||||
1. **Phase 2 – Fast password for demo users:** Add `derive-password-weak` in `backend/src/app/auth.clj` (e.g., bcrypt cost 4 or SHA-256). Update `backend/src/app/rpc/commands/demo.clj` to use it for all demo profiles. This reduces `setup()` time from ~2–3 min to ~5–10 sec for 1000 users. **Does not affect production** — `demo-users` flag is disabled by default in production.
|
||||
2. Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes).
|
||||
3. Phase 4: Concurrent editing test (2–3 VUs per file, measure conflict rate).
|
||||
4. Phase 5: Grafana dashboard panels (p95 latency by RPC, error rate, JVM, DB pool).
|
||||
5. Add `--scenario` flag to `run.sh` to select individual flows by name from the orchestrator.
|
||||
6. Write `viewer.js` — `get-view-only-bundle` + `get-comment-threads` (deferred per user request).
|
||||
|
||||
---
|
||||
|
||||
@ -317,6 +335,28 @@ The backend `update-file` performance depends heavily on file data size (seriali
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 – Performance Optimization: Fast Password Hashing for Demo Users
|
||||
|
||||
**Goal:** Reduce `setup()` time for performance tests by making demo profile password derivation faster.
|
||||
|
||||
**Problem:** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2. At 1000 VUs, creating the user pool in `setup()` takes ~2–3 minutes just for password hashing.
|
||||
|
||||
**Solution:**
|
||||
Since `demo-users` is already a development-only feature (disabled by default in production), we can simplify: all demo profiles should use a weaker, faster password algorithm. No special parameters or tenant checks needed.
|
||||
|
||||
1. In `backend/src/app/auth.clj`, add a `derive-password-weak` function that uses a faster algorithm (e.g., bcrypt cost 4 or SHA-256). Keep the default `derive-password` as argon2id for regular users.
|
||||
2. In `backend/src/app/rpc/commands/demo.clj`, use `derive-password-weak` instead of `derive-password` when creating demo profiles. This is safe because `demo-users` is already gated behind a config flag and is only used in development/testing.
|
||||
|
||||
**Files to touch:**
|
||||
- `backend/src/app/auth.clj` — add `derive-password-weak` function (e.g., bcrypt cost 4 or SHA-256)
|
||||
- `backend/src/app/rpc/commands/demo.clj` — use `derive-password-weak` instead of `derive-password`
|
||||
|
||||
**Expected impact:** Setup time for 1000 users drops from ~2–3 min to ~5–10 sec.
|
||||
|
||||
**Safety:** Demo users are already a development-only feature (disabled by default in production via `demo-users` config flag). Using weaker passwords for demo users only affects development/test environments where the flag is explicitly enabled.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 – Scenarios & Orchestration (Day 6)
|
||||
|
||||
Define k6 `options.scenarios` that mix the flows to simulate realistic traffic.
|
||||
@ -483,5 +523,5 @@ Run `workspace-edit.js` against each tier separately and plot:
|
||||
---
|
||||
|
||||
**Plan Author:** Senior Software Architect
|
||||
**Status:** Phase 1–3 complete (all core flows + orchestrator). Phase 4–5 remain. See "Current Progress" above.
|
||||
**Status:** Phase 1–3 complete. Backend UUID fix + setup() user pool applied. Scripts ready for 1000 VU scale. Phase 4–5 remain.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user