mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 10:01:38 -04:00
* ✨ Add plugin with composable test framework and component tests The plugin provides a framework for writing composable tests against the Plugin API, and applies it to systematic end-to-end testing of component semantics. The framework's core ideas: a test is written once as a composition of operations over a starting configuration; choice points among the operations (optional steps, alternatives) expand the composition into a full sweep of test variants, so a single case definition yields broad combinatorial coverage; and the operations drive the real Plugin API with real change propagation, testing the full production implementation. The initial application is a suite of component test cases covering synchronization, overrides, swap slots and variants — the TypeScript/e2e continuation of the ClojureScript composable test suite (frontend_tests.composable_tests). Several cases originate from reproducing real defects (e.g. #10109 and the swap-slot corruptions). Tests run from an interactive panel in Penpot: cases are listed with plain-language descriptions, tests can be run selectively, results stream in live, and every checkbox carries a stable DOM id — so the panel can equally be driven programmatically (the basis for running the suite in CI), as documented in the plugin's README. Lives at plugins/apps/composable-test-suite as a regular member of the plugins workspace (init script, start:plugin:composable-test-suite, shared dev port 4202, covered by build:plugins via the new ./apps/*-test-suite filter). Related to #10584. AI-assisted-by: claude-fable-5 * ✨ Run the composable test suite headlessly in CI Adds a headless run mode for the composable test suite, following the plugin-api-test-suite's CI architecture, and a workflow that runs it as a per-PR gate. An in-sandbox entry (src/ci/headless.ts) runs the suite without the panel UI — the framework's runner was UI-free by construction, so no refactoring was needed — and streams each result through console markers, addressed by the same composite identifiers the panel uses (e.g. MainEditSyncs-2), with durations and, on failure, the error and the applied-steps transcript. It is built as a single self-executing bundle and evaluated directly inside a real Penpot plugin sandbox by the driver (ci/run-ci.ts), so no plugin dev server or port is involved. The driver needs no backend and no login: it serves the prebuilt frontend bundle via the frontend e2e static server and intercepts every backend RPC with Playwright fixtures. The mocked backend is not a limitation for this suite — everything it asserts is frontend store logic executed in memory — which the full run confirms: all 48 tests behave identically to the interactive panel, including variants and swap slots, with the single (currently expected) failure of MainEditSyncs-2 reproducing bug #10109 under the mock. TEST_FILTER selects tests by identifier substring; CI_TIMEOUT_MS bounds the run. The mock harness mirrors the frontend e2e harness (see the provenance note in the driver). Related to #10584. AI-assisted-by: claude-fable-5 * 📚 Restructure the composable-tests memory around both suites Present the composable component tests top-down: the shared framework principles upfront, then the two implementations — the ClojureScript suite in the frontend test tree and the TypeScript suite in the plugin, which tests fully end-to-end with a slightly more elaborate set of abstractions — and the plugin's headless CI run, pointing to the plugin's README for operational details. Also records this session's additions (geometry operations, case N, the CI harness). AI-assisted-by: claude-fable-5 * 📎 Refine the PR-description conventions in the creating-prs memory Encourage digestible descriptions: bullet items over prose (grouped by area with bold lead-ins for larger PRs) and no manual line wraps, since the rendered markdown adapts to the viewport. Also drop the outdated 'MCP' from the standard Note line. AI-assisted-by: claude-fable-5 * 🔧 Set Prettier endOfLine to auto in plugins workspace Prettier defaults to endOfLine "lf", which is incompatible with checkouts on Windows that use core.autocrlf=true * 🐛 Fix problems with suite --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { chromium, type Page } from "playwright";
|
|
|
|
// Out-of-sandbox CI driver (Node + Playwright) for the composable test suite,
|
|
// following the plugin-api-test-suite's CI driver. NOTE on provenance: the mock
|
|
// harness here (the RPC fixture table, the WebSocket mock, the get-file fixture)
|
|
// exists in three places that must stay in sync — the frontend e2e harness
|
|
// (frontend/playwright, the origin), the plugin-api-test-suite driver, and this
|
|
// file. If workspace loading changes and this driver times out waiting for the
|
|
// viewport, diff against those two first. It serves the prebuilt
|
|
// frontend bundle via the frontend e2e static server, intercepts every backend
|
|
// RPC with Playwright `page.route` (reusing the frontend e2e mock fixtures),
|
|
// injects the prebuilt `headless.js` bundle into the plugin sandbox via
|
|
// `globalThis.ɵloadPlugin`, and captures the results from the page console.
|
|
//
|
|
// No backend and no login are needed: everything the suite asserts (component
|
|
// synchronization, overrides, swap slots, variants) is frontend store logic,
|
|
// executed optimistically in memory; the backend's only role is persistence,
|
|
// which the mock answers with a canned 200.
|
|
//
|
|
// Optional env:
|
|
// - TEST_FILTER: run only tests whose composite identifier contains the given
|
|
// substring (case-insensitive), e.g. "MainEditSyncs" or "MainEditSyncs-2".
|
|
// - CI_TIMEOUT_MS: overall timeout waiting for results (default 600000).
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
// here = <root>/plugins/apps/composable-test-suite/ci
|
|
const repoRoot = resolve(here, "../../../../");
|
|
const frontendDir = resolve(repoRoot, "frontend");
|
|
const e2eDataDir = resolve(frontendDir, "playwright/data");
|
|
|
|
const BASE_URL = "http://localhost:3000";
|
|
|
|
const headlessBundlePath = resolve(here, "../dist/headless.js");
|
|
|
|
// Source the permissions from the same manifest the real plugin ships with, so
|
|
// the CI sandbox never drifts from what users actually grant.
|
|
const manifestPath = resolve(here, "../public/manifest.json");
|
|
const PERMISSIONS: string[] = (JSON.parse(readFileSync(manifestPath, "utf-8")) as { permissions: string[] })
|
|
.permissions;
|
|
|
|
// Ids of the mocked full-feature file fixture (`ci/fixtures/get-file.json`),
|
|
// kept in sync with the frontend e2e fixtures.
|
|
const MOCK_TEAM_ID = "c7ce0794-0992-8105-8004-38e630f7920a";
|
|
const MOCK_FILE_ID = "c7ce0794-0992-8105-8004-38f280443849";
|
|
const MOCK_PAGE_ID = "66697432-c33d-8055-8006-2c62cc084cad";
|
|
|
|
// Workspace-load RPCs mirrored from the frontend e2e harness. Maps RPC glob ->
|
|
// fixture file relative to frontend/playwright/data.
|
|
const MOCK_RPCS: Record<string, string> = {
|
|
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
|
"get-teams": "get-teams.json",
|
|
"get-team?id=*": "workspace/get-team-default.json",
|
|
"get-team-members?team-id=*": "logged-in-user/get-team-members-your-penpot.json",
|
|
"get-team-users?file-id=*": "logged-in-user/get-team-users-single-user.json",
|
|
"get-project?id=*": "workspace/get-project-default.json",
|
|
"get-comment-threads?file-id=*": "workspace/get-comment-threads-empty.json",
|
|
"get-profiles-for-file-comments?file-id=*": "workspace/get-profile-for-file-comments.json",
|
|
"get-file-object-thumbnails?file-id=*": "workspace/get-file-object-thumbnails-blank.json",
|
|
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
|
|
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
|
|
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
|
|
"update-profile-props": "workspace/update-profile-empty.json",
|
|
};
|
|
|
|
// Persistence (`update-file`) response shape the frontend expects: it reads
|
|
// `revn`/`lagged` (persistence.cljs `update-file-revn`). `revn` is merged with
|
|
// `max`, so a low value is harmless.
|
|
const UPDATE_FILE_RESPONSE = JSON.stringify({ "~:revn": 1, "~:lagged": [] });
|
|
|
|
interface ReportedResult {
|
|
identifier: string;
|
|
name: string;
|
|
passed: boolean;
|
|
durationMs: number;
|
|
error?: string;
|
|
transcript?: string[];
|
|
}
|
|
|
|
async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
|
|
const start = Date.now();
|
|
for (;;) {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (res.ok || res.status === 404) return; // static server is up
|
|
} catch {
|
|
/* not up yet */
|
|
}
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error(`Timed out waiting for server at ${url}`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
}
|
|
|
|
function startE2eServer(): ChildProcess {
|
|
// Reuse the frontend e2e static server: it serves frontend/resources/public
|
|
// on port 3000, which is also the host the app opens its notifications
|
|
// WebSocket against — so the WS mock below matches without extra config.
|
|
return spawn("node", ["scripts/e2e-server.js"], {
|
|
cwd: frontendDir,
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
|
|
// Install the frontend e2e WebSocket mock so the workspace's notifications
|
|
// socket can be "opened" without a backend.
|
|
async function installWebSocketMock(page: Page): Promise<Set<string>> {
|
|
const created = new Set<string>();
|
|
await page.exposeFunction("onMockWebSocketConstructor", (url: string) => {
|
|
created.add(url);
|
|
});
|
|
await page.addInitScript({
|
|
path: resolve(frontendDir, "playwright/scripts/MockWebSocket.js"),
|
|
});
|
|
return created;
|
|
}
|
|
|
|
async function openNotificationsWebSocket(page: Page, created: Set<string>): Promise<void> {
|
|
const start = Date.now();
|
|
let wsUrl: string | undefined;
|
|
while (!wsUrl) {
|
|
wsUrl = [...created].find((u) => u.includes("ws/notifications"));
|
|
if (wsUrl) break;
|
|
if (Date.now() - start > 30000) {
|
|
throw new Error("Timed out waiting for notifications WebSocket");
|
|
}
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
await page.evaluate((url) => {
|
|
(WebSocket as unknown as { getByURL: (u: string) => { mockOpen: () => void } | undefined })
|
|
.getByURL(url)
|
|
?.mockOpen();
|
|
}, wsUrl);
|
|
}
|
|
|
|
async function setupMockedRoutes(page: Page): Promise<void> {
|
|
// Config flags: deterministic empty flags.
|
|
await page.route("**/js/config.js*", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/javascript",
|
|
body: 'var penpotFlags = "";\n',
|
|
})
|
|
);
|
|
|
|
// Workspace-load RPCs from the frontend e2e fixtures.
|
|
for (const [rpc, fixture] of Object.entries(MOCK_RPCS)) {
|
|
await page.route(`**/api/main/methods/${rpc}`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/transit+json",
|
|
path: resolve(e2eDataDir, fixture),
|
|
})
|
|
);
|
|
}
|
|
|
|
// get-file: the custom full-feature fixture (enables plugins/runtime,
|
|
// design-tokens/v1, variants/v1, ...). Without these features active the
|
|
// plugin runtime never initialises.
|
|
await page.route(/\/api\/main\/methods\/get-file\?/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/transit+json",
|
|
path: resolve(here, "fixtures/get-file.json"),
|
|
})
|
|
);
|
|
|
|
// Blanket no-op persistence: the suite mutates the in-memory store
|
|
// optimistically, so a 200 `update-file` mock is enough.
|
|
await page.route(/\/api\/main\/methods\/update-file\b/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/transit+json",
|
|
body: UPDATE_FILE_RESPONSE,
|
|
})
|
|
);
|
|
}
|
|
|
|
function fileUrl(): string {
|
|
return `${BASE_URL}/#/workspace?team-id=${MOCK_TEAM_ID}&file-id=${MOCK_FILE_ID}&page-id=${MOCK_PAGE_ID}`;
|
|
}
|
|
|
|
function printReport(results: ReportedResult[]) {
|
|
// Each result is already printed live as it streams in; here we only recap
|
|
// the failures (with their transcripts) so they're easy to find at the
|
|
// bottom of a long run.
|
|
const failures = results.filter((r) => !r.passed);
|
|
if (failures.length > 0) {
|
|
console.log("\nFailures:");
|
|
for (const r of failures) {
|
|
console.log(` ✗ ${r.identifier} — ${r.name} (${r.durationMs}ms)`);
|
|
if (r.error) console.log(` ${r.error}`);
|
|
for (const step of r.transcript ?? []) {
|
|
console.log(` · ${step}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const bundle = readFileSync(headlessBundlePath, "utf-8");
|
|
|
|
const server = startE2eServer();
|
|
await waitForServer(BASE_URL);
|
|
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
|
|
const wsCreated = await installWebSocketMock(page);
|
|
await setupMockedRoutes(page);
|
|
|
|
// The bundle runs inside an SES Compartment (its own `globalThis`), so a
|
|
// page `addInitScript` global can't reach it. Prepend the filter straight
|
|
// into the evaluated code.
|
|
const filter = process.env["TEST_FILTER"];
|
|
const injectedCode = filter
|
|
? `globalThis.__COMPOSABLE_SUITE_FILTER__ = ${JSON.stringify(filter)};\n${bundle}`
|
|
: bundle;
|
|
|
|
const results: ReportedResult[] = [];
|
|
let fatal: string | null = null;
|
|
|
|
console.log("\nRunning tests:");
|
|
const done = new Promise<void>((resolvePromise) => {
|
|
page.on("console", (msg) => {
|
|
const text = msg.text();
|
|
if (text.startsWith("__TEST_RESULT__ ")) {
|
|
const result: ReportedResult = JSON.parse(text.slice("__TEST_RESULT__ ".length));
|
|
results.push(result);
|
|
const icon = result.passed ? "✓" : "✗";
|
|
console.log(` ${icon} ${result.identifier} — ${result.name} (${result.durationMs}ms)`);
|
|
if (!result.passed && result.error) {
|
|
console.log(` ${result.error}`);
|
|
}
|
|
} else if (text.startsWith("__TEST_DONE__ ")) {
|
|
resolvePromise();
|
|
} else if (text.startsWith("__TEST_FATAL__ ")) {
|
|
fatal = (JSON.parse(text.slice("__TEST_FATAL__ ".length)) as { message: string }).message;
|
|
resolvePromise();
|
|
}
|
|
});
|
|
});
|
|
|
|
await page.goto(fileUrl());
|
|
await openNotificationsWebSocket(page, wsCreated);
|
|
|
|
await page.waitForSelector('[data-testid="viewport"]');
|
|
// The plugin runtime initialises asynchronously after the file's features
|
|
// are active; wait for the loader to be exposed before injecting.
|
|
await page.waitForFunction(
|
|
() => typeof (globalThis as unknown as { ɵloadPlugin?: unknown }).ɵloadPlugin === "function",
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await page.evaluate(
|
|
({ code, permissions }) => {
|
|
(globalThis as unknown as { ɵloadPlugin: (m: unknown) => void }).ɵloadPlugin({
|
|
pluginId: "00000000-0000-0000-0000-000000000000",
|
|
name: "Composable Test Suite (CI)",
|
|
code,
|
|
icon: "",
|
|
description: "",
|
|
permissions,
|
|
});
|
|
},
|
|
{ code: injectedCode, permissions: PERMISSIONS }
|
|
);
|
|
|
|
const timeoutMs = Number(process.env["CI_TIMEOUT_MS"] ?? 600000);
|
|
await Promise.race([
|
|
done,
|
|
new Promise<void>((_, reject) =>
|
|
setTimeout(() => reject(new Error("Timed out waiting for test results")), timeoutMs)
|
|
),
|
|
]);
|
|
|
|
await browser.close();
|
|
server.kill();
|
|
|
|
printReport(results);
|
|
|
|
if (fatal) {
|
|
console.error(`\nFatal error while running tests: ${fatal}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const failed = results.filter((r) => !r.passed).length;
|
|
const passed = results.filter((r) => r.passed).length;
|
|
console.log(`\n${passed} passed, ${failed} failed.`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|