penpot/performance/scripts/lifecycle.js
2026-06-12 14:08:55 +02:00

422 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Lifecycle Performance Test
//
// Simulates a realistic user lifecycle from registration through CRUD operations.
// Each VU performs the full flow independently, creating its own artifacts.
//
// 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
//
// Usage:
// k6 run 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";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
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 = {
scenarios: {
lifecycle: {
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:login-with-password}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-profile}": ["p(95)<500"],
"http_req_duration{rpc_command:create-project}": ["p(95)<1000"],
"http_req_duration{rpc_command:create-file}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<2000"],
"http_req_duration{rpc_command:delete-file}": ["p(95)<1000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
// 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;
const y = 100;
const w = 200;
const h = 150;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId, // required: the frame this shape belongs to
parentId: pageId, // root frame is the page itself
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,
},
points: [
{ x: x, y: y },
{ x: x + w, y: y },
{ x: x + w, y: y + h },
{ x: x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId,
frameId: pageId,
},
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
// Try to get the response body for debugging
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 — runs once before VUs start
// ---------------------------------------------------------------------------
export function setup() {
console.log(`Penpot Lifecycle Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Register mode: ${REGISTER_MODE}`);
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}`);
}
return { baseUrl: BASE_URL, registerMode: REGISTER_MODE };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
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}`);
}
sleep(1);
// ---- Step 1: Login ----
const loginRes = client.login(email, 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");
}
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");
}
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}`);
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}`);
sleep(1);
// ---- Step 6: Get the file (to read revn, vern, page-id) ----
const getFileRes = client.getFile(fileId);
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`);
}
sleep(1);
// ---- Step 7: Update file (add a rectangle shape) ----
let updateOk = false;
if (pageId) {
const changes = [makeAddRectChange(pageId)];
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status === 200) {
updateOk = true;
console.log(`VU ${__VU}: Updated file successfully`);
} else {
// Check for revn conflict — retry once
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`);
}
}
} 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).
if (testImageSmall && testImageSmall.byteLength > 0) {
const uploadRes = client.uploadFileMediaObject(
fileId,
testImageSmall,
"test-small.png",
"image/png"
);
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"
);
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}`);
}
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}`);
}
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`);
}
// ---------------------------------------------------------------------------
// Teardown — runs once after all VUs finish
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Lifecycle test complete.");
}