mirror of
https://github.com/penpot/penpot.git
synced 2026-06-18 13:22:08 +00:00
422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
// 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.");
|
||
}
|