diff --git a/frontend/playwright/fixtures/workspace/get-comment-threads-empty.json b/frontend/playwright/fixtures/workspace/get-comment-threads-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/fixtures/workspace/get-comment-threads-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/fixtures/workspace/get-file-blank.json b/frontend/playwright/fixtures/workspace/get-file-blank.json new file mode 100644 index 000000000..9e05e3b50 --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/get-file-fragment-blank.json b/frontend/playwright/fixtures/workspace/get-file-fragment-blank.json new file mode 100644 index 000000000..fe357c500 --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/get-file-libraries-empty.json b/frontend/playwright/fixtures/workspace/get-file-libraries-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/fixtures/workspace/get-file-libraries-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/fixtures/workspace/get-file-object-thumbnails-blank.json b/frontend/playwright/fixtures/workspace/get-file-object-thumbnails-blank.json new file mode 100644 index 000000000..8f55ece27 --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/get-font-variants-empty.json b/frontend/playwright/fixtures/workspace/get-font-variants-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/frontend/playwright/fixtures/workspace/get-font-variants-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/fixtures/workspace/get-profile-for-file-comments.json b/frontend/playwright/fixtures/workspace/get-profile-for-file-comments.json new file mode 100644 index 000000000..f11319ecf --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/get-project-default.json b/frontend/playwright/fixtures/workspace/get-project-default.json new file mode 100644 index 000000000..d953da8fd --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/get-team-default.json b/frontend/playwright/fixtures/workspace/get-team-default.json new file mode 100644 index 000000000..e31dcf90c --- /dev/null +++ b/frontend/playwright/fixtures/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/fixtures/workspace/ws-notifications.js b/frontend/playwright/fixtures/workspace/ws-notifications.js new file mode 100644 index 000000000..4ab58d147 --- /dev/null +++ b/frontend/playwright/fixtures/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/helpers/MockAPI.js b/frontend/playwright/helpers/MockAPI.js new file mode 100644 index 000000000..8a5e970c4 --- /dev/null +++ b/frontend/playwright/helpers/MockAPI.js @@ -0,0 +1,17 @@ +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}`, + }), + ); + +export const interceptRPCByRegex = (page, regex, jsonFilename) => + page.route(regex, (route) => + route.fulfill({ + status: 200, + contentType: "application/transit+json", + path: `playwright/fixtures/${jsonFilename}`, + }), + ); diff --git a/frontend/playwright/helpers/MockRPC.js b/frontend/playwright/helpers/MockRPC.js deleted file mode 100644 index b08b9678d..000000000 --- a/frontend/playwright/helpers/MockRPC.js +++ /dev/null @@ -1,8 +0,0 @@ -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 index 53d7612b7..18134ff78 100644 --- a/frontend/playwright/helpers/MockWebSocket.js +++ b/frontend/playwright/helpers/MockWebSocket.js @@ -1,81 +1,5 @@ -export class MockWebSocket extends EventTarget { - static #mocks = new Map(); - +export class WebSocketManager { 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/login.spec.js b/frontend/playwright/login.spec.js index 18279b95a..cade62385 100644 --- a/frontend/playwright/login.spec.js +++ b/frontend/playwright/login.spec.js @@ -1,6 +1,5 @@ import { test, expect } from "@playwright/test"; -import { interceptRPC } from "./helpers/MockRPC"; -import { MockWebSocket } from "./helpers/MockWebSocket"; +import { interceptRPC } from "./helpers/MockAPI"; const setupLoggedOutUser = async (page) => { await interceptRPC(page, "get-profile", "get-profile-anonymous.json"); @@ -30,17 +29,9 @@ const setupDashboardUser = async (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", - ); + 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); @@ -62,8 +53,5 @@ test("User logs in by filling the login form", async ({ page }) => { 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 index 457955449..a7449827e 100644 --- a/frontend/playwright/scripts/MockWebSocket.js +++ b/frontend/playwright/scripts/MockWebSocket.js @@ -1,4 +1,3 @@ -console.log("MockWebSocket mock loaded"); window.WebSocket = class MockWebSocket extends EventTarget { static CONNECTING = 0; static OPEN = 1; @@ -15,6 +14,19 @@ window.WebSocket = class MockWebSocket extends EventTarget { return this.#mocks.get(url); } + static waitForURL(url) { + return new Promise((resolve) => { + let intervalID = setInterval(() => { + for (const [wsURL, ws] of this.#mocks) { + if (wsURL.includes(url)) { + clearInterval(intervalID); + resolve(ws); + } + } + }, 30); + }); + } + #url; #protocols; #protocol = ""; @@ -32,10 +44,9 @@ window.WebSocket = class MockWebSocket extends EventTarget { #spyClose = null; constructor(url, protocols) { + console.log("🤖 New websocket at", url); super(); - console.log("MockWebSocket", url, protocols); - this.#url = url; this.#protocols = protocols || []; @@ -84,13 +95,13 @@ window.WebSocket = class MockWebSocket extends EventTarget { } 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.removeEventListener("open", this.#onopen); + this.#onopen = null; + + if (typeof callback === "function") { this.addEventListener("open", callback); + this.#onopen = callback; } - this.#onopen = callback; } get onopen() { @@ -98,13 +109,13 @@ window.WebSocket = class MockWebSocket extends EventTarget { } 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.removeEventListener("error", this.#onerror); + this.#onerror = null; + + if (typeof callback === "function") { this.addEventListener("error", callback); + this.#onerror = callback; } - this.#onerror = callback; } get onerror() { @@ -112,13 +123,13 @@ window.WebSocket = class MockWebSocket extends EventTarget { } 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.removeEventListener("message", this.#onmessage); + this.#onmessage = null; + + if (typeof callback === "function") { this.addEventListener("message", callback); + this.#onmessage = callback; } - this.#onmessage = callback; } get onmessage() { @@ -126,13 +137,13 @@ window.WebSocket = class MockWebSocket extends EventTarget { } 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.removeEventListener("close", this.#onclose); + this.#onclose = null; + + if (typeof callback === "function") { this.addEventListener("close", callback); + this.#onclose = callback; } - this.#onclose = callback; } get onclose() { @@ -160,6 +171,7 @@ window.WebSocket = class MockWebSocket extends EventTarget { } mockOpen(options) { + console.log("🤖 open mock"); this.#protocol = options?.protocol || ""; this.#extensions = options?.extensions || ""; this.#readyState = MockWebSocket.OPEN; @@ -174,9 +186,12 @@ window.WebSocket = class MockWebSocket extends EventTarget { } mockMessage(data) { + console.log("🤯 mock message"); if (this.#readyState !== MockWebSocket.OPEN) { + console.log("socket is not connected"); throw new Error("MockWebSocket is not connected"); } + console.log("😰 dispatching `message`", { data }); this.dispatchEvent(new MessageEvent("message", { data })); return this; } @@ -188,16 +203,16 @@ window.WebSocket = class MockWebSocket extends EventTarget { } 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); + + if (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"); } @@ -214,7 +229,8 @@ window.WebSocket = class MockWebSocket extends EventTarget { } this.#readyState = MockWebSocket.CLOSING; - console.log("MockWebSocket close"); - this.#spyClose && this.#spyClose(this.url, code, reason); + if (this.#spyClose) { + this.#spyClose(this.url, code, reason); + } } -} +}; diff --git a/frontend/playwright/workspace.spec.js b/frontend/playwright/workspace.spec.js new file mode 100644 index 000000000..ff0bc5fee --- /dev/null +++ b/frontend/playwright/workspace.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { interceptRPC, interceptRPCByRegex } from "./helpers/MockAPI"; +import { WebSocketManager } from "./helpers/MockWebSocket"; +import { presenceFixture } from "./fixtures/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) => { + interceptRPC(page, "get-profile", "logged-in-user/get-profile-logged-in.json"); + interceptRPC(page, "get-team-users?file-id=*", "logged-in-user/get-team-users-single-user.json"); + interceptRPC(page, "get-comment-threads?file-id=*", "workspace/get-comment-threads-empty.json"); + interceptRPC(page, "get-project?id=*", "workspace/get-project-default.json"); + interceptRPC(page, "get-team?id=*", "workspace/get-team-default.json"); + interceptRPCByRegex(page, /get\-file\?/, "workspace/get-file-blank.json"); + interceptRPC( + page, + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + interceptRPC( + page, + "get-profiles-for-file-comments?file-id=*", + "workspace/get-profile-for-file-comments.json", + ); + interceptRPC(page, "get-font-variants?team-id=*", "workspace/get-font-variants-empty.json"); + interceptRPC(page, "get-file-fragment?file-id=*", "workspace/get-file-fragment-blank.json"); + interceptRPC(page, "get-file-libraries?file-id=*", "workspace/get-file-libraries-empty.json"); +}; + +test.beforeEach(async ({ page }) => { + await WebSocketManager.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.only("User receives notifications updates in the workspace", async ({ page }) => { + await setupWorkspaceUser(page); + await page.goto(`/#/workspace/${anyProjectId}/${anyFileId}?page-id=${anyPageId}`); + + await page.evaluate(async () => { + const ws = await WebSocket.waitForURL("ws://0.0.0.0:3500/ws/notifications"); + ws.mockOpen(); + }); + + await expect(page.getByTestId("page-name")).toHaveText("Page 1"); + + await page.evaluate( + async ({ presenceFixture }) => { + const ws = await WebSocket.waitForURL("ws://0.0.0.0:3500/ws/notifications"); + ws.mockMessage(JSON.stringify(presenceFixture)); + }, + { presenceFixture }, + ); + + expect(page.getByTestId("active-users-list").getByAltText("Princesa Leia")).toHaveCount(2); + + await page.evaluate(async () => { + const ws = await WebSocket.waitForURL("ws://0.0.0.0:3500/ws/notifications"); + ws.mockClose(); + }); +}); diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 4bb3a9772..b721c2485 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -162,6 +162,7 @@ (assoc :text-color "#000000"))) (update-presence [presence] + (js/console.log "🥰 WIIIIII" (clj->js presence)) (-> presence (update session-id update-session presence) (d/without-nils)))] 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?))