mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 20:32:04 +00:00
269 lines
8.5 KiB
JavaScript
269 lines
8.5 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.
|
|
//
|
|
// 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. 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
|
|
|
|
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";
|
|
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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(` VUs: ${vuCount}`);
|
|
console.log(` Creating ${vuCount} demo profiles...`);
|
|
console.log(``);
|
|
|
|
const client = createClient(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());
|
|
}
|
|
|
|
console.log(` Created ${users.length} demo profiles`);
|
|
return { baseUrl: BASE_URL, users };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main VU Function
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function (data) {
|
|
const client = createClient(data.baseUrl);
|
|
|
|
// Pick user from pool
|
|
const user = data.users[__VU - 1];
|
|
if (!user) {
|
|
fail(`No user for VU ${__VU} (pool size: ${data.users.length})`);
|
|
}
|
|
|
|
// ---- Step 1: Login ----
|
|
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;
|
|
|
|
sleep(1);
|
|
|
|
// ---- Step 2: Get profile ----
|
|
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 defaultTeamId = teamsRes.body[0].id;
|
|
|
|
sleep(0.5);
|
|
|
|
// ---- Step 4: Create a project ----
|
|
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 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 ----
|
|
const getFileRes = client.getFile(fileId);
|
|
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
|
|
const fileData = getFileRes.body;
|
|
const pageId = fileData.data.pages[0];
|
|
|
|
sleep(1);
|
|
|
|
// ---- Step 7: Update file (add a shape) ----
|
|
if (pageId) {
|
|
const changes = [makeAddRectChange(pageId)];
|
|
const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes);
|
|
|
|
if (updateRes.status !== 200) {
|
|
// 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);
|
|
|
|
// ---- Step 8: Upload images ----
|
|
if (testImageSmall && testImageSmall.byteLength > 0) {
|
|
assertOk(
|
|
client.uploadFileMediaObject(fileId, testImageSmall, "sample.png", "image/png"),
|
|
"upload (direct)"
|
|
);
|
|
}
|
|
sleep(0.5);
|
|
if (testImageLarge && testImageLarge.byteLength > 0) {
|
|
assertOk(
|
|
client.uploadFileMediaObject(fileId, testImageLarge, "sample.jpg", "image/jpeg"),
|
|
"upload (chunked)"
|
|
);
|
|
}
|
|
|
|
sleep(1);
|
|
|
|
// ---- Step 9: Delete file ----
|
|
assertOk(client.deleteFile(fileId), "delete-file");
|
|
sleep(0.5);
|
|
|
|
// ---- Step 10: Delete project ----
|
|
assertOk(client.deleteProject(projectId), "delete-project");
|
|
sleep(0.5);
|
|
|
|
// ---- Step 11: Logout ----
|
|
client.logout(profileId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Teardown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function teardown(data) {
|
|
console.log("Lifecycle test complete.");
|
|
}
|