mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 21:06:14 +00:00
* ⚡ Build the frontend bundle once for all E2E suites Merge tests-integration, tests-composable-suite and tests-plugin-api-suite into one "CI: E2E" workflow. Each of the three ran its own full frontend/scripts/build on every PR, so one PR paid the build three times. The new build-bundle job restores actions/cache key frontend-bundle-<sha>, runs frontend/scripts/build only on a miss and saves the key before the job ends. The integration shards, the composable suite and the mocked Plugin API suite now all need build-bundle and restore the same key with fail-on-cache-miss, so none of them builds. A workflow re-run of the same SHA reuses the cached bundle instead of rebuilding it. Triggers become the union of the previous paths (frontend, common, render-wasm, plugins): the bundle embeds the built plugins, so a plugins change runs the whole set. workflow_dispatch keeps running the integration job only, as before. Job names are kept identical on purpose: they are the GitHub check contexts and branch protection may match them by name. Docs: new mem:frontend/e2e-ci-workflow records the build-once contract, referenced from mem:frontend/core and mem:frontend/testing; the composable memory and both suite READMEs are updated. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix mocked plugin suites crashing without frontend deps The mocked CI drivers shelled out to frontend/scripts/e2e-server.js, which imports express from frontend/node_modules. CI jobs install only plugins/ deps, so the import failed with ERR_MODULE_NOT_FOUND and the run timed out waiting for localhost:3000. Serve the prebuilt bundle with a zero-dependency static server built into each driver (ci/static-server.ts, kept in sync in both suites) plus node:test coverage for it. AI-assisted-by: muse-spark-1.3-contributor
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { chromium, type Page } from "playwright";
|
|
import { startStaticServer, type StaticServer } from "./static-server.ts";
|
|
|
|
// 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 with the zero-dependency static server in
|
|
// `ci/static-server.ts`, 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 staticRoot = resolve(frontendDir, "resources/public");
|
|
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(): Promise<StaticServer> {
|
|
// Serve the prebuilt frontend bundle from `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.
|
|
// This used to shell out to the express-based
|
|
// `frontend/scripts/e2e-server.js`, but that resolves `express` from
|
|
// `frontend/node_modules`, which the CI jobs never install (only
|
|
// `plugins/` deps), so the driver crashed before serving anything.
|
|
return startStaticServer(staticRoot, 3000);
|
|
}
|
|
|
|
// 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 = await 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();
|
|
await server.close();
|
|
|
|
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);
|
|
});
|