From 30321e54f0928ae1aecd76d87e44cd6f147f3b04 Mon Sep 17 00:00:00 2001 From: AzazelN28 Date: Thu, 25 Apr 2024 14:14:46 +0200 Subject: [PATCH] :paperclip: Add WebSocket mock --- .gitignore | 4 + .../get-builtin-templates-empty.json | 1 + frontend/playwright/helpers/MockRPC.js | 8 + frontend/playwright/helpers/MockWebSocket.js | 81 +++++++ frontend/playwright/helpers/index.js | 14 -- frontend/playwright/login.spec.js | 69 ++++++ frontend/playwright/scripts/MockWebSocket.js | 220 ++++++++++++++++++ package-lock.json | 91 ++++++++ package.json | 4 + playwright.config.ts | 77 ++++++ yarn.lock | 44 +++- 11 files changed, 589 insertions(+), 24 deletions(-) create mode 100644 frontend/playwright/data/logged-in-user/get-builtin-templates-empty.json create mode 100644 frontend/playwright/helpers/MockRPC.js create mode 100644 frontend/playwright/helpers/MockWebSocket.js delete mode 100644 frontend/playwright/helpers/index.js create mode 100644 frontend/playwright/login.spec.js create mode 100644 frontend/playwright/scripts/MockWebSocket.js create mode 100644 package-lock.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 0e271d125..9b3cdd4b0 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ clj-profiler/ node_modules frontend/.storybook/preview-body.html +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/playwright/data/logged-in-user/get-builtin-templates-empty.json b/frontend/playwright/data/logged-in-user/get-builtin-templates-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/data/logged-in-user/get-builtin-templates-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/helpers/MockRPC.js b/frontend/playwright/helpers/MockRPC.js new file mode 100644 index 000000000..b08b9678d --- /dev/null +++ b/frontend/playwright/helpers/MockRPC.js @@ -0,0 +1,8 @@ +export const interceptRPC = (page, path, jsonFilename) => + page.route(`**/api/rpc/command/${path}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/transit+json", + path: `playwright/fixtures/${jsonFilename}`, + }) + ); diff --git a/frontend/playwright/helpers/MockWebSocket.js b/frontend/playwright/helpers/MockWebSocket.js new file mode 100644 index 000000000..53d7612b7 --- /dev/null +++ b/frontend/playwright/helpers/MockWebSocket.js @@ -0,0 +1,81 @@ +export class MockWebSocket extends EventTarget { + static #mocks = new Map(); + + static async init(page) { + await page.exposeFunction('MockWebSocket$$constructor', (url, protocols) => { + console.log('MockWebSocket$$constructor', MockWebSocket, url, protocols) + const webSocket = new MockWebSocket(page, url, protocols); + this.#mocks.set(url, webSocket); + }); + await page.exposeFunction('MockWebSocket$$spyMessage', (url, data) => { + console.log('MockWebSocket$$spyMessage', url, data) + this.#mocks.get(url).dispatchEvent(new MessageEvent('message', { data })) + }); + await page.exposeFunction('MockWebSocket$$spyClose', (url, code, reason) => { + console.log('MockWebSocket$$spyClose', url, code, reason) + this.#mocks.get(url).dispatchEvent(new CloseEvent('close', { code, reason })) + }); + await page.addInitScript({ path: "playwright/scripts/MockWebSocket.js" }); + } + + static waitForURL(url) { + return new Promise((resolve) => { + let intervalID = setInterval(() => { + for (const [wsURL, ws] of this.#mocks) { + console.log(wsURL) + if (wsURL.includes(url)) { + clearInterval(intervalID); + return resolve(ws); + } + } + }, 30) + }) + } + + #page = null + #url + #protocols + + // spies. + #spyClose = null + #spyMessage = null + + constructor(page, url, protocols) { + super() + this.#page = page + this.#url = url + this.#protocols = protocols + } + + mockOpen(options) { + return this.#page.evaluate((options) => { + WebSocket.getByURL(url).mockOpen(options) + }, options) + } + + mockMessage(data) { + return this.#page.evaluate((data) => { + WebSocket.getByURL(url).mockMessage(data) + }, data) + } + + mockClose() { + return this.#page.evaluate(() => { + WebSocket.getByURL(url).mockClose() + }) + } + + spyClose(fn) { + if (typeof fn !== 'function') { + throw new TypeError('Invalid callback') + } + this.#spyClose = fn + } + + spyMessage(fn) { + if (typeof fn !== 'function') { + throw new TypeError('Invalid callback') + } + this.#spyMessage = fn + } +} diff --git a/frontend/playwright/helpers/index.js b/frontend/playwright/helpers/index.js deleted file mode 100644 index ac8108f81..000000000 --- a/frontend/playwright/helpers/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export const interceptRPC = async (page, path, jsonFilename, options = {}) => { - const interceptConfig = { - status: 200, - ...options, - }; - - await page.route(`**/api/rpc/command/${path}`, async (route) => { - await route.fulfill({ - ...interceptConfig, - contentType: "application/transit+json", - path: `playwright/data/${jsonFilename}`, - }); - }); -}; diff --git a/frontend/playwright/login.spec.js b/frontend/playwright/login.spec.js new file mode 100644 index 000000000..18279b95a --- /dev/null +++ b/frontend/playwright/login.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { interceptRPC } from "./helpers/MockRPC"; +import { MockWebSocket } from "./helpers/MockWebSocket"; + +const setupLoggedOutUser = async (page) => { + await interceptRPC(page, "get-profile", "get-profile-anonymous.json"); + await interceptRPC(page, "login-with-password", "logged-in-user/login-with-password-success.json"); +}; + +// TODO: maybe Playwright's fixtures are the right way to do this? +const setupDashboardUser = async (page) => { + await interceptRPC(page, "get-profile", "logged-in-user/get-profile-logged-in.json"); + await interceptRPC(page, "get-teams", "logged-in-user/get-teams-default.json"); + await interceptRPC(page, "get-font-variants?team-id=*", "logged-in-user/get-font-variants-empty.json"); + await interceptRPC(page, "get-projects?team-id=*", "logged-in-user/get-projects-default.json"); + await interceptRPC(page, "get-team-members?team-id=*", "logged-in-user/get-team-members-your-penpot.json"); + await interceptRPC(page, "get-team-users?team-id=*", "logged-in-user/get-team-users-single-user.json"); + await interceptRPC( + page, + "get-unread-comment-threads?team-id=*", + "logged-in-user/get-team-users-single-user.json", + ); + await interceptRPC( + page, + "get-team-recent-files?team-id=*", + "logged-in-user/get-team-recent-files-empty.json", + ); + await interceptRPC( + page, + "get-profiles-for-file-comments", + "logged-in-user/get-profiles-for-file-comments-empty.json", + ); + await interceptRPC( + page, + "get-builtin-templates", + "logged-in-user/get-builtin-templates-empty.json", + ); +}; + +test.beforeEach(async ({ page }) => { + await MockWebSocket.init(page); +}) + +test("Shows login page when going to index and user is logged out", async ({ page }) => { + await setupLoggedOutUser(page); + + await page.goto("/"); + + await expect(page).toHaveURL(/auth\/login$/); + await expect(page.getByText("Log into my account")).toBeVisible(); +}); + +test("User logs in by filling the login form", async ({ page }) => { + await setupLoggedOutUser(page); + + await page.goto("/#/auth/login"); + + await setupDashboardUser(page); + + await page.getByLabel("Email").fill("foo@example.com"); + await page.getByLabel("Password").fill("loremipsum"); + + await page.getByRole("button", { name: "Login" }).click(); + + const ws = await MockWebSocket.waitForURL('ws://0.0.0.0:3500/ws/notifications'); + console.log(ws) + + await expect(page).toHaveURL(/dashboard/); +}); diff --git a/frontend/playwright/scripts/MockWebSocket.js b/frontend/playwright/scripts/MockWebSocket.js new file mode 100644 index 000000000..457955449 --- /dev/null +++ b/frontend/playwright/scripts/MockWebSocket.js @@ -0,0 +1,220 @@ +console.log("MockWebSocket mock loaded"); +window.WebSocket = class MockWebSocket extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + static #mocks = new Map(); + + static getAll() { + return this.#mocks.values(); + } + + static getByURL(url) { + return this.#mocks.get(url); + } + + #url; + #protocols; + #protocol = ""; + #binaryType = "blob"; + #bufferedAmount = 0; + #extensions = ""; + #readyState = MockWebSocket.CONNECTING; + + #onopen = null; + #onerror = null; + #onmessage = null; + #onclose = null; + + #spyMessage = null; + #spyClose = null; + + constructor(url, protocols) { + super(); + + console.log("MockWebSocket", url, protocols); + + this.#url = url; + this.#protocols = protocols || []; + + MockWebSocket.#mocks.set(this.#url, this); + + if (typeof window["MockWebSocket$$constructor"] === "function") { + MockWebSocket$$constructor(this.#url, this.#protocols); + } + if (typeof window["MockWebSocket$$spyMessage"] === "function") { + this.#spyMessage = MockWebSocket$$spyMessage; + } + if (typeof window["MockWebSocket$$spyClose"] === "function") { + this.#spyClose = MockWebSocket$$spyClose; + } + } + + set binaryType(binaryType) { + if (!["blob", "arraybuffer"].includes(binaryType)) { + return; + } + this.#binaryType = binaryType; + } + + get binaryType() { + return this.#binaryType; + } + + get bufferedAmount() { + return this.#bufferedAmount; + } + + get extensions() { + return this.#extensions; + } + + get readyState() { + return this.#readyState; + } + + get protocol() { + return this.#protocol; + } + + get url() { + return this.#url; + } + + set onopen(callback) { + if (callback === null) { + this.removeEventListener("open", this.#onopen); + } else if (typeof callback === "function") { + if (this.#onopen) this.removeEventListener("open", this.#onopen); + this.addEventListener("open", callback); + } + this.#onopen = callback; + } + + get onopen() { + return this.#onopen; + } + + set onerror(callback) { + if (callback === null) { + this.removeEventListener("error", this.#onerror); + } else if (typeof callback === "function") { + if (this.#onerror) this.removeEventListener("error", this.#onerror); + this.addEventListener("error", callback); + } + this.#onerror = callback; + } + + get onerror() { + return this.#onerror; + } + + set onmessage(callback) { + if (callback === null) { + this.removeEventListener("message", this.#onmessage); + } else if (typeof callback === "function") { + if (this.#onmessage) this.removeEventListener("message", this.#onmessage); + this.addEventListener("message", callback); + } + this.#onmessage = callback; + } + + get onmessage() { + return this.#onmessage; + } + + set onclose(callback) { + if (callback === null) { + this.removeEventListener("close", this.#onclose); + } else if (typeof callback === "function") { + if (this.#onclose) this.removeEventListener("close", this.#onclose); + this.addEventListener("close", callback); + } + this.#onclose = callback; + } + + get onclose() { + return this.#onclose; + } + + get mockProtocols() { + return this.#protocols; + } + + spyClose(callback) { + if (typeof callback !== "function") { + throw new TypeError("Invalid callback"); + } + this.#spyClose = callback; + return this; + } + + spyMessage(callback) { + if (typeof callback !== "function") { + throw new TypeError("Invalid callback"); + } + this.#spyMessage = callback; + return this; + } + + mockOpen(options) { + this.#protocol = options?.protocol || ""; + this.#extensions = options?.extensions || ""; + this.#readyState = MockWebSocket.OPEN; + this.dispatchEvent(new Event("open")); + return this; + } + + mockError(error) { + this.#readyState = MockWebSocket.CLOSED; + this.dispatchEvent(new ErrorEvent("error", { error })); + return this; + } + + mockMessage(data) { + if (this.#readyState !== MockWebSocket.OPEN) { + throw new Error("MockWebSocket is not connected"); + } + this.dispatchEvent(new MessageEvent("message", { data })); + return this; + } + + mockClose(code, reason) { + this.#readyState = MockWebSocket.CLOSED; + this.dispatchEvent(new CloseEvent("close", { code: code || 1000, reason: reason || "" })); + return this; + } + + send(data) { + console.log(data); + if (this.#readyState === MockWebSocket.CONNECTING) { + throw new DOMException("InvalidStateError", "MockWebSocket is not connected"); + } + console.log(`MockWebSocket send: ${data}`); + this.#spyMessage && this.#spyMessage(this.url, data); + } + + close(code, reason) { + console.log(code, reason); + if (code && !Number.isInteger(code) && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException("InvalidAccessError", "Invalid code"); + } + + if (reason && typeof reason === "string") { + const reasonBytes = new TextEncoder().encode(reason); + if (reasonBytes.length > 123) { + throw new DOMException("SyntaxError", "Reason is too long"); + } + } + + if ([MockWebSocket.CLOSED, MockWebSocket.CLOSING].includes(this.#readyState)) { + return; + } + + this.#readyState = MockWebSocket.CLOSING; + console.log("MockWebSocket close"); + this.#spyClose && this.#spyClose(this.url, code, reason); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..8c0dc2cbc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,91 @@ +{ + "name": "penpot", + "version": "1.20.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "penpot", + "version": "1.20.0", + "license": "MPL-2.0", + "devDependencies": { + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7" + } + }, + "node_modules/@playwright/test": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "dev": true, + "dependencies": { + "playwright": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "dev": true, + "dependencies": { + "playwright-core": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json index 94f482998..d26aae7be 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,9 @@ "lint:clj:backend": "clj-kondo --parallel=true --lint backend/src", "lint:clj:exporter": "clj-kondo --parallel=true --lint exporter/src", "lint:clj": "yarn run lint:clj:common && yarn run lint:clj:frontend && yarn run lint:clj:backend && yarn run lint:clj:exporter" + }, + "devDependencies": { + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..301801ee1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/yarn.lock b/yarn.lock index 9a4b9536a..b3d605679 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,12 +1,36 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 -__metadata: - version: 8 - cacheKey: 10c0 -"penpot@workspace:.": - version: 0.0.0-use.local - resolution: "penpot@workspace:." - languageName: unknown - linkType: soft +"@playwright/test@^1.43.1": + version "1.43.1" + resolved "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz" + integrity sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA== + dependencies: + playwright "1.43.1" + +"@types/node@^20.12.7": + version "20.12.7" + resolved "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz" + integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg== + dependencies: + undici-types "~5.26.4" + +playwright-core@1.43.1: + version "1.43.1" + resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz" + integrity sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg== + +playwright@1.43.1: + version "1.43.1" + resolved "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz" + integrity sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA== + dependencies: + playwright-core "1.43.1" + optionalDependencies: + fsevents "2.3.2" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==