Merge pull request #6646 from penpot/ladybenko-10904-playwright-wasm

🔧 Set up visual regression tests for wasm renderer
This commit is contained in:
Alejandro Alonso
2025-06-10 09:24:59 +02:00
committed by GitHub
16 changed files with 3565 additions and 15 deletions

1
frontend/.gitignore vendored
View File

@@ -11,3 +11,4 @@ node_modules/
/blob-report/
/playwright/.cache/
/playwright/**/visual-specs/**/*.png

View File

@@ -53,6 +53,21 @@ export default defineConfig({
toHaveScreenshot: { maxDiffPixelRatio: 0.005 },
},
},
{
name: "render-wasm",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 }, // Add custom viewport size
deviceScaleFactor: 2,
},
testDir: "./playwright/ui/render-wasm-specs",
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.005,
},
},
},
],
/* Run your local dev server before starting the tests */

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,33 @@ export class BasePage {
);
}
static async mockAsset(page, assetId, assetFilename, options) {
const ids = Array.isArray(assetId) ? assetId : [assetId];
for (const id of ids) {
const url = `**/assets/by-file-media-id/${id}`;
await page.route(url, (route) =>
route.fulfill({
path: `playwright/data/${assetFilename}`,
status: 200,
...options,
}),
);
}
}
static async mockConfigFlags(page, flags) {
const url = "**/js/config.js?ts=*";
return await page.route(url, (route) =>
route.fulfill({
status: 200,
contentType: "application/javascript",
body: `var penpotFlags = "${flags.join(" ")}";`,
}),
);
}
#page = null;
constructor(page) {
@@ -38,14 +65,11 @@ export class BasePage {
}
async mockConfigFlags(flags) {
const url = "**/js/config.js?ts=*";
return await this.page.route(url, (route) =>
route.fulfill({
status: 200,
contentType: "application/javascript",
body: `var penpotFlags = "${flags.join(" ")}";`,
}),
);
return BasePage.mockConfigFlags(this.page, flags);
}
async mockAsset(assetId, assetFilename, options) {
return BasePage.mockAsset(this.page, assetId, assetFilename, options);
}
}

View File

@@ -0,0 +1,31 @@
import { expect } from "@playwright/test";
import { WorkspacePage } from "./WorkspacePage";
export class WasmWorkspacePage extends WorkspacePage {
static async init(page) {
await super.init(page);
await WorkspacePage.mockConfigFlags(page, [
"enable-feature-render-wasm",
"enable-render-wasm-dpr",
]);
await page.addInitScript(() => {
document.addEventListener("wasm:set-objects-finished", () => {
window.wasmSetObjectsFinished = true;
});
});
}
constructor(page) {
super(page);
this.canvas = page.getByTestId("canvas-wasm-shapes");
}
async waitForFirstRender() {
await expect(this.pageName).toHaveText("Page 1");
await this.canvas.waitFor({ state: "visible" });
await this.page.waitForFunction(() => {
return window.wasmSetObjectsFinished;
});
}
}

View File

@@ -178,6 +178,10 @@ export class WorkspacePage extends BaseWebSocketPage {
);
}
async mockGetFile(jsonFile) {
await this.mockRPC(/get\-file\?/, jsonFile);
}
async setupFileWithComments() {
await this.mockRPC(
"get-comment-threads?file-id=*",

View File

@@ -0,0 +1,71 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, [
"enable-feature-render-wasm",
"enable-render-wasm-dpr",
]);
});
test("Renders a file with basic shapes, boards and groups", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-shapes-groups-boards.json");
await workspace.goToWorkspace({
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
});
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with solid, gradient and image fills", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockAsset(
[
"1ebcea38-f1bf-8101-8006-4c8fd68e7c84",
"1ebcea38-f1bf-8101-8006-4c8f579da49c",
],
"render-wasm/assets/penguins.jpg",
);
await workspace.mockGetFile("render-wasm/get-file-shapes-fills.json");
await workspace.goToWorkspace({
id: "1ebcea38-f1bf-8101-8006-4c8ec4a9bffe",
pageId: "1ebcea38-f1bf-8101-8006-4c8ec4a9bfff",
});
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with strokes", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockAsset(
[
"202c1104-9385-81d3-8006-5074e4682cac",
"202c1104-9385-81d3-8006-5074c50339b6",
"202c1104-9385-81d3-8006-507560ce29e3",
],
"render-wasm/assets/penguins.jpg",
);
await workspace.mockGetFile("render-wasm/get-file-shapes-strokes.json");
await workspace.goToWorkspace({
id: "202c1104-9385-81d3-8006-507413ff2c99",
pageId: "202c1104-9385-81d3-8006-507413ff2c9a",
});
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -361,6 +361,7 @@
:viewport-ref viewport-ref}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
:ref canvas-ref
:class (stl/css :render-shapes)
:key (dm/str "render" page-id)

View File

@@ -766,13 +766,17 @@
(defn process-pending
[pending]
(when-let [pending (-> (d/index-by :key :callback pending) vals)]
(->> (rx/from pending)
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])
(rx/subs! (fn [_]
(clear-drawing-cache)
(request-render "set-objects"))))))
(let [event (js/CustomEvent. "wasm:set-objects-finished")
pending (-> (d/index-by :key :callback pending) vals)]
(if (not-empty? pending)
(->> (rx/from pending)
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])
(rx/subs! (fn [_]
(clear-drawing-cache)
(request-render "set-objects")
(.dispatchEvent ^js js/document event))))
(.dispatchEvent ^js js/document event))))
(defn process-object
[shape]

View File

@@ -0,0 +1,53 @@
# Visual regression tests
> ⚠️ At the time being, these tests are to be run _locally_. They are not
> executed in CI yet.
## Run the tests
The tests are located in their own Playwright project, `render-wasm`. To run them, go to the `frontend` dir and execute Playwright passing the `--project` flag:
```zsh
cd frontend
npx playwright test --ui --project=render-wasm
```
## Write new tests
You need to add a new spec file to `frontend/playwright/ui/render-wasm-specs` or add a test to one of the existing specs. You can use `shapes.spec.js` as reference.
Writing the tests is very similar to write any other test for Penpot. However, some helpers have been added to address some issues specific to the new render engine.
### Step 1: Initialize the page
There is a page helper, `WasmWorkspacePage` that contains:
- Automatically setting the right flags to enable the new wasm renderer.
- A helper function to wait for the first render to happen, `waitForFirstRender`.
- A locator for the `<canvas>` element in the viewport, `canvas`.
> :⚠️: An await for `waitForFirstRender` is crucial, specially if the render depends on requests that are run in a promise, like fetching images or fonts (even if they are mocked/intercepted!).
### Step 2: Intercept requests
The main requests of the API to intercept are: `/get-file` and `/assets/by-file-media-id/`
If you disable the feature flag `fdata/pointer-map` in your local environment, you will get a JSON response from `/get-file` that does _not_ include fragments, so you will not have to mock `get-file-fragment` too.
For mocking the assets, a new helper has been added: `mockAsset`. This accepts either an asset ID or an array of IDs.
### Step 3: Go to workspace and take a screenshot
After calling `goToWorkspace`, you need to add this to ensure that the `<canvas>` has been drawn into:
```js
await workspace.waitForFirstRender();
```
To take a screenshot, simply call:
```js
await expect(workspace.canvas).toHaveScreenshot();
```
Note that the test will fail the very first time you call it, because it will have no screenshot to compare to. It should work on following runs.