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/data/workspace/get-comment-threads-empty.json b/frontend/playwright/data/workspace/get-comment-threads-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/data/workspace/get-comment-threads-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-file-blank.json b/frontend/playwright/data/workspace/get-file-blank.json new file mode 100644 index 000000000..9e05e3b50 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-blank.json @@ -0,0 +1,58 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 1", + "~:revn": 11, + "~:modified-at": "~m1713873823633", + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:is-shared": false, + "~:version": 46, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713536343369", + "~:data": { + "~:pages": [ + "~uc7ce0794-0992-8105-8004-38f28044384a" + ], + "~:pages-index": { + "~uc7ce0794-0992-8105-8004-38f28044384a": { + "~#penpot/pointer": [ + "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", + { + "~:created-at": "~m1713873823636" + } + ] + } + }, + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#0000ff", + "~:opacity": 1, + "~:id": null, + "~:file-id": null, + "~:image": null + } + ] + } +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-file-fragment-blank.json b/frontend/playwright/data/workspace/get-file-fragment-blank.json new file mode 100644 index 000000000..fe357c500 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-fragment-blank.json @@ -0,0 +1,97 @@ +{ + "~:id": "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", + "~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:created-at": "~m1713873823631", + "~:content": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [] + } + } + }, + "~:id": "~uc7ce0794-0992-8105-8004-38f28044384a", + "~:name": "Page 1" + } +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-file-libraries-empty.json b/frontend/playwright/data/workspace/get-file-libraries-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-libraries-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-file-object-thumbnails-blank.json b/frontend/playwright/data/workspace/get-file-object-thumbnails-blank.json new file mode 100644 index 000000000..8f55ece27 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-object-thumbnails-blank.json @@ -0,0 +1,3 @@ +{ + "c7ce0794-0992-8105-8004-38f280443849/c7ce0794-0992-8105-8004-38f28044384a/8c1035fa-01f0-8071-8004-3df966ff2c64/frame": "http://localhost:3449/assets/by-id/50d097ed-d321-4319-b00b-e82a9c9435ea" +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-font-variants-empty.json b/frontend/playwright/data/workspace/get-font-variants-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/data/workspace/get-font-variants-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-profile-for-file-comments.json b/frontend/playwright/data/workspace/get-profile-for-file-comments.json new file mode 100644 index 000000000..f11319ecf --- /dev/null +++ b/frontend/playwright/data/workspace/get-profile-for-file-comments.json @@ -0,0 +1,9 @@ +[ + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:email": "foo@example.com", + "~:name": "Princesa Leia", + "~:fullname": "Princesa Leia", + "~:is-active": true + } +] \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-project-default.json b/frontend/playwright/data/workspace/get-project-default.json new file mode 100644 index 000000000..d953da8fd --- /dev/null +++ b/frontend/playwright/data/workspace/get-project-default.json @@ -0,0 +1,8 @@ +{ + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116382", + "~:modified-at": "~m1713873823633", + "~:is-default": true, + "~:name": "Drafts" +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/get-team-default.json b/frontend/playwright/data/workspace/get-team-default.json new file mode 100644 index 000000000..e31dcf90c --- /dev/null +++ b/frontend/playwright/data/workspace/get-team-default.json @@ -0,0 +1,23 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116375", + "~:is-default": true +} diff --git a/frontend/playwright/data/workspace/ws-notifications.js b/frontend/playwright/data/workspace/ws-notifications.js new file mode 100644 index 000000000..4ab58d147 --- /dev/null +++ b/frontend/playwright/data/workspace/ws-notifications.js @@ -0,0 +1,7 @@ +export const presenceFixture = { + "~:type": "~:presence", + "~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:session-id": "~u37730924-d520-80f1-8004-4ae6e5c3942d", + "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:subs-id": "~uc7ce0794-0992-8105-8004-38f280443849", +}; diff --git a/frontend/playwright/fixtures/login-fixture.js b/frontend/playwright/fixtures/login-fixture.js new file mode 100644 index 000000000..866453793 --- /dev/null +++ b/frontend/playwright/fixtures/login-fixture.js @@ -0,0 +1,10 @@ +import { test as base } from '@playwright/test' + +export const test = base.extend({ + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page) + await use(loginPage) + } +}) + +export { expect } from '@playwright/test' diff --git a/frontend/playwright/helpers/MockWebSocketHelper.js b/frontend/playwright/helpers/MockWebSocketHelper.js new file mode 100644 index 000000000..3f0f845d2 --- /dev/null +++ b/frontend/playwright/helpers/MockWebSocketHelper.js @@ -0,0 +1,74 @@ +export class MockWebSocketHelper extends EventTarget { + static #mocks = new Map(); + + static async init(page) { + await page.exposeFunction("MockWebSocket$$constructor", (url, protocols) => { + const webSocket = new MockWebSocketHelper(page, url, protocols); + this.#mocks.set(url, webSocket); + }); + await page.exposeFunction("MockWebSocket$$spyMessage", (url, data) => { + if (!this.#mocks.has(url)) { + throw new Error(`WebSocket with URL ${url} not found`); + } + this.#mocks.get(url).dispatchEvent(new MessageEvent("message", { data })); + }); + await page.exposeFunction("MockWebSocket$$spyClose", (url, code, reason) => { + if (!this.#mocks.has(url)) { + throw new Error(`WebSocket with URL ${url} not found`); + } + 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) => { + const intervalID = setInterval(() => { + for (const [wsURL, ws] of this.#mocks) { + if (wsURL.includes(url)) { + clearInterval(intervalID); + return resolve(ws); + } + } + }, 30); + }); + } + + #page = null; + #url; + #protocols; + + constructor(page, url, protocols) { + super(); + this.#page = page; + this.#url = url; + this.#protocols = protocols; + } + + mockOpen(options) { + return this.#page.evaluate(({ url, options }) => { + if (typeof WebSocket.getByURL !== 'function') { + throw new Error('WebSocket.getByURL is not a function. Did you forget to call MockWebSocket.init(page)?') + } + WebSocket.getByURL(url).mockOpen(options); + }, { url: this.#url, options }); + } + + mockMessage(data) { + return this.#page.evaluate(({ url, data }) => { + if (typeof WebSocket.getByURL !== 'function') { + throw new Error('WebSocket.getByURL is not a function. Did you forget to call MockWebSocket.init(page)?') + } + WebSocket.getByURL(url).mockMessage(data); + }, { url: this.#url, data }); + } + + mockClose() { + return this.#page.evaluate(({ url }) => { + if (typeof WebSocket.getByURL !== 'function') { + throw new Error('WebSocket.getByURL is not a function. Did you forget to call MockWebSocket.init(page)?') + } + WebSocket.getByURL(url).mockClose(); + }, { url: this.#url }); + } +} 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/helpers/intercepts.js b/frontend/playwright/helpers/intercepts.js deleted file mode 100644 index ecb46b817..000000000 --- a/frontend/playwright/helpers/intercepts.js +++ /dev/null @@ -1,8 +0,0 @@ -import { interceptRPC } from "./index"; - - -export const setupNotLogedIn = async (page) => { - await interceptRPC(page, "get-profile", "get-profile-anonymous.json"); - -}; - diff --git a/frontend/playwright/scripts/MockWebSocket.js b/frontend/playwright/scripts/MockWebSocket.js new file mode 100644 index 000000000..b7f5e4e30 --- /dev/null +++ b/frontend/playwright/scripts/MockWebSocket.js @@ -0,0 +1,226 @@ +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) { + if (this.#mocks.has(url)) { + return this.#mocks.get(url); + } + for (const [wsURL, ws] of this.#mocks) { + if (wsURL.includes(url)) { + return ws; + } + } + return undefined; + } + + #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(); + + 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) { + this.removeEventListener("open", this.#onopen); + this.#onopen = null; + + if (typeof callback === "function") { + this.addEventListener("open", callback); + this.#onopen = callback; + } + } + + get onopen() { + return this.#onopen; + } + + set onerror(callback) { + this.removeEventListener("error", this.#onerror); + this.#onerror = null; + + if (typeof callback === "function") { + this.addEventListener("error", callback); + this.#onerror = callback; + } + } + + get onerror() { + return this.#onerror; + } + + set onmessage(callback) { + this.removeEventListener("message", this.#onmessage); + this.#onmessage = null; + + if (typeof callback === "function") { + this.addEventListener("message", callback); + this.#onmessage = callback; + } + } + + get onmessage() { + return this.#onmessage; + } + + set onclose(callback) { + this.removeEventListener("close", this.#onclose); + this.#onclose = null; + + if (typeof callback === "function") { + 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) { + if (this.#readyState === MockWebSocket.CONNECTING) { + throw new DOMException("InvalidStateError", "MockWebSocket is not connected"); + } + + if (this.#spyMessage) { + this.#spyMessage(this.url, data); + } + } + + close(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; + if (this.#spyClose) { + this.#spyClose(this.url, code, reason); + } + } +}; diff --git a/frontend/playwright/ui/pages/BasePage.js b/frontend/playwright/ui/pages/BasePage.js new file mode 100644 index 000000000..e0e62a0a1 --- /dev/null +++ b/frontend/playwright/ui/pages/BasePage.js @@ -0,0 +1,38 @@ +export class BasePage { + static async mockRPC(page, path, jsonFilename, options) { + if (!page) { + throw new TypeError("Invalid page argument. Must be a Playwright page."); + } + if (typeof path !== "string" && !(path instanceof RegExp)) { + throw new TypeError("Invalid path argument. Must be a string or a RegExp."); + } + const url = typeof path === "string" ? `**/api/rpc/command/${path}` : path; + const interceptConfig = { + status: 200, + contentType: "application/transit+json", + ...options, + }; + return page.route(url, (route) => + route.fulfill({ + ...interceptConfig, + path: `playwright/data/${jsonFilename}`, + }), + ); + } + + #page = null; + + constructor(page) { + this.#page = page; + } + + get page() { + return this.#page; + } + + async mockRPC(path, jsonFilename, options) { + return BasePage.mockRPC(this.page, path, jsonFilename, options); + } +} + +export default BasePage; diff --git a/frontend/playwright/ui/pages/BaseWebSocketPage.js b/frontend/playwright/ui/pages/BaseWebSocketPage.js new file mode 100644 index 000000000..f76a7f5c4 --- /dev/null +++ b/frontend/playwright/ui/pages/BaseWebSocketPage.js @@ -0,0 +1,32 @@ +import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper"; +import BasePage from "./BasePage"; + +export class BaseWebSocketPage extends BasePage { + /** + * This should be called on `test.beforeEach`. + * + * @param {Page} page + * @returns + */ + static setupWebSockets(page) { + return MockWebSocketHelper.init(page); + } + + /** + * Returns a promise that resolves when a WebSocket with the given URL is created. + * + * @param {string} url + * @returns {Promise} + */ + async waitForWebSocket(url) { + return MockWebSocketHelper.waitForURL(url); + } + + /** + * + * @returns {Promise} + */ + async waitForNotificationsWebSocket() { + return this.waitForWebSocket("ws://0.0.0.0:3500/ws/notifications"); + } +} diff --git a/frontend/playwright/ui/pages/LoginPage.js b/frontend/playwright/ui/pages/LoginPage.js new file mode 100644 index 000000000..e0acf8b12 --- /dev/null +++ b/frontend/playwright/ui/pages/LoginPage.js @@ -0,0 +1,54 @@ +import { BasePage } from "./BasePage"; + +export class LoginPage extends BasePage { + static setupLoggedOutUser(page) { + return this.mockRPC(page, "get-profile", "get-profile-anonymous.json"); + } + + constructor(page) { + super(page); + this.loginButton = page.getByRole("button", { name: "Login" }); + this.password = page.getByLabel("Password"); + this.userName = page.getByLabel("Email"); + this.message = page.getByText("Email or password is incorrect"); + this.badLoginMsg = page.getByText("Enter a valid email please"); + this.initialHeading = page.getByRole("heading", { name: "Log into my account" }); + } + + async fillEmailAndPasswordInputs(email, password) { + await this.userName.fill(email); + await this.password.fill(password); + } + + async clickLoginButton() { + await this.loginButton.click(); + } + + async setupAllowedUser() { + await this.mockRPC("get-profile", "logged-in-user/get-profile-logged-in.json"); + await this.mockRPC("get-teams", "logged-in-user/get-teams-default.json"); + await this.mockRPC("get-font-variants?team-id=*", "logged-in-user/get-font-variants-empty.json"); + await this.mockRPC("get-projects?team-id=*", "logged-in-user/get-projects-default.json"); + await this.mockRPC("get-team-members?team-id=*", "logged-in-user/get-team-members-your-penpot.json"); + await this.mockRPC("get-team-users?team-id=*", "logged-in-user/get-team-users-single-user.json"); + await this.mockRPC( + "get-unread-comment-threads?team-id=*", + "logged-in-user/get-team-users-single-user.json", + ); + await this.mockRPC("get-team-recent-files?team-id=*", "logged-in-user/get-team-recent-files-empty.json"); + await this.mockRPC( + "get-profiles-for-file-comments", + "logged-in-user/get-profiles-for-file-comments-empty.json", + ); + } + + async setupLoginSuccess() { + await this.mockRPC("login-with-password", "logged-in-user/login-with-password-success.json"); + } + + async setupLoginError() { + await this.mockRPC("login-with-password", "login-with-password-error.json", { status: 400 }); + } +} + +export default LoginPage; diff --git a/frontend/playwright/ui/pages/login-page.js b/frontend/playwright/ui/pages/login-page.js deleted file mode 100644 index 1358f4ab3..000000000 --- a/frontend/playwright/ui/pages/login-page.js +++ /dev/null @@ -1,76 +0,0 @@ -import { interceptRPC } from "../../helpers/index"; - -class LoginPage { - constructor(page) { - this.page = page; - this.loginButton = page.getByRole("button", { name: "Login" }); - this.password = page.getByLabel("Password"); - this.userName = page.getByLabel("Email"); - this.message = page.getByText("Email or password is incorrect"); - this.badLoginMsg = page.getByText("Enter a valid email please"); - this.initialHeading = page.getByRole("heading", { name: "Log into my account" }); - } - - url() { - return this.page.url(); - } - - context() { - return this.page.context(); - } - - async fillEmailAndPasswordInputs(email, password) { - await this.userName.fill(email); - await this.password.fill(password); - } - - async clickLoginButton() { - await this.loginButton.click(); - } - - async setupAllowedUser() { - await interceptRPC(this.page, "get-profile", "logged-in-user/get-profile-logged-in.json"); - await interceptRPC(this.page, "get-teams", "logged-in-user/get-teams-default.json"); - await interceptRPC( - this.page, - "get-font-variants?team-id=*", - "logged-in-user/get-font-variants-empty.json", - ); - await interceptRPC(this.page, "get-projects?team-id=*", "logged-in-user/get-projects-default.json"); - await interceptRPC( - this.page, - "get-team-members?team-id=*", - "logged-in-user/get-team-members-your-penpot.json", - ); - await interceptRPC( - this.page, - "get-team-users?team-id=*", - "logged-in-user/get-team-users-single-user.json", - ); - await interceptRPC( - this.page, - "get-unread-comment-threads?team-id=*", - "logged-in-user/get-team-users-single-user.json", - ); - await interceptRPC( - this.page, - "get-team-recent-files?team-id=*", - "logged-in-user/get-team-recent-files-empty.json", - ); - await interceptRPC( - this.page, - "get-profiles-for-file-comments", - "logged-in-user/get-profiles-for-file-comments-empty.json", - ); - } - - async setupLoginSuccess() { - await interceptRPC(this.page, "login-with-password", "logged-in-user/login-with-password-success.json"); - } - - async setupLoginError() { - await interceptRPC(this.page, "login-with-password", "login-with-password-error.json", { status: 400 }); - } -} - -export default LoginPage; diff --git a/frontend/playwright/ui/specs/login.spec.js b/frontend/playwright/ui/specs/login.spec.js index 08d3753c4..dab5a5ca6 100644 --- a/frontend/playwright/ui/specs/login.spec.js +++ b/frontend/playwright/ui/specs/login.spec.js @@ -1,10 +1,8 @@ import { test, expect } from "@playwright/test"; -import { setupNotLogedIn } from "../../helpers/intercepts"; - -import LoginPage from "../pages/login-page"; +import { LoginPage } from "../pages/LoginPage"; test.beforeEach(async ({ page }) => { - await setupNotLogedIn(page); + await LoginPage.setupLoggedOutUser(page); await page.goto("/#/auth/login"); }); @@ -13,7 +11,7 @@ test("Shows login page when going to index and user is logged out", async ({ pag await loginPage.setupAllowedUser(); - await expect(loginPage.url()).toMatch(/auth\/login$/); + await expect(loginPage.page).toHaveURL(/auth\/login$/); await expect(loginPage.initialHeading).toBeVisible(); }); @@ -37,8 +35,7 @@ test("User logs in by filling the login form", async ({ page }) => { await loginPage.clickLoginButton(); await page.waitForURL('**/dashboard/**'); - await expect(page).toHaveURL(/dashboard/); - // await expect(loginPage.url()).toMatch(/dashboard/); + await expect(loginPage.page).toHaveURL(/dashboard/); }); test("User submits wrong credentials", async ({ page }) => { @@ -50,5 +47,5 @@ test("User submits wrong credentials", async ({ page }) => { await loginPage.clickLoginButton(); await expect(loginPage.message).toBeVisible(); - await expect(loginPage.url()).toMatch(/auth\/login$/); + await expect(loginPage.page).toHaveURL(/auth\/login$/); }); diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js new file mode 100644 index 000000000..3f24e2d28 --- /dev/null +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test"; +import { BasePage } from "../pages/BasePage"; +import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper"; +import { presenceFixture } from "../../data/workspace/ws-notifications"; + +const anyProjectId = "c7ce0794-0992-8105-8004-38e630f7920b"; +const anyFileId = "c7ce0794-0992-8105-8004-38f280443849"; +const anyPageId = "c7ce0794-0992-8105-8004-38f28044384a"; + +const setupWorkspaceUser = (page) => { + BasePage.mockRPC(page, "get-profile", "logged-in-user/get-profile-logged-in.json"); + BasePage.mockRPC(page, "get-team-users?file-id=*", "logged-in-user/get-team-users-single-user.json"); + BasePage.mockRPC(page, "get-comment-threads?file-id=*", "workspace/get-comment-threads-empty.json"); + BasePage.mockRPC(page, "get-project?id=*", "workspace/get-project-default.json"); + BasePage.mockRPC(page, "get-team?id=*", "workspace/get-team-default.json"); + BasePage.mockRPC(page, /get\-file\?/, "workspace/get-file-blank.json"); + BasePage.mockRPC( + page, + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + BasePage.mockRPC( + page, + "get-profiles-for-file-comments?file-id=*", + "workspace/get-profile-for-file-comments.json", + ); + BasePage.mockRPC(page, "get-font-variants?team-id=*", "workspace/get-font-variants-empty.json"); + BasePage.mockRPC(page, "get-file-fragment?file-id=*", "workspace/get-file-fragment-blank.json"); + BasePage.mockRPC(page, "get-file-libraries?file-id=*", "workspace/get-file-libraries-empty.json"); +}; + +test.beforeEach(async ({ page }) => { + await MockWebSocketHelper.init(page); +}); + +test("User loads worskpace with empty file", async ({ page }) => { + await setupWorkspaceUser(page); + + await page.goto(`/#/workspace/${anyProjectId}/${anyFileId}?page-id=${anyPageId}`); + + await expect(page.getByTestId("page-name")).toHaveText("Page 1"); +}); + +test("User receives notifications updates in the workspace", async ({ page }) => { + await setupWorkspaceUser(page); + await page.goto(`/#/workspace/${anyProjectId}/${anyFileId}?page-id=${anyPageId}`); + + const ws = await MockWebSocketHelper.waitForURL("ws://0.0.0.0:3500/ws/notifications") + await ws.mockOpen(); + await expect(page.getByTestId("page-name")).toHaveText("Page 1"); + await ws.mockMessage(JSON.stringify(presenceFixture)); + await expect(page.getByTestId("active-users-list").getByAltText("Princesa Leia")).toHaveCount(2); + await ws.mockClose(); +}); diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs index cdf6c6e23..6a071fbda 100644 --- a/frontend/src/app/main/ui/workspace/presence.cljs +++ b/frontend/src/app/main/ui/workspace/presence.cljs @@ -56,7 +56,7 @@ :class (stl/css :active-users-opened) :on-click on-close :on-blur on-close} - [:ul {:class (stl/css :active-users-list)} + [:ul {:class (stl/css :active-users-list) :data-testid "active-users-list"} (for [session sessions] [:& session-widget {:color (:color session) @@ -66,7 +66,7 @@ [:button {:class (stl/css-case :active-users true) :on-click on-open} - [:ul {:class (stl/css :active-users-list)} + [:ul {:class (stl/css :active-users-list) :data-testid "active-users-list"} (when (> num-sessions 2) [:span {:class (stl/css :users-num)} (dm/str "+" (- num-sessions 2))]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 975615048..39064f7c7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -144,7 +144,7 @@ :auto-focus true :default-value (:name page "")}]] [:* - [:span {:class (stl/css :page-name)} + [:span {:class (stl/css :page-name) :data-testid "page-name"} (:name page)] [:div {:class (stl/css :page-actions)} (when (and deletable? (not workspace-read-only?)) 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==