snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
Some checks failed
build / test (18.x) (push) Failing after 10s
build / lint (18.x) (push) Failing after 0s
build / docker-image (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 16:16:17 +05:00
commit d57328655f
233 changed files with 21282 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { Page, expect, test } from "@playwright/test"
export function BoardApi(page: Page) {
return {
async getBoard(accessToken: string, boardId: string) {
const response = await page.request.get(`/api/v1/board/${boardId}`, {
headers: {
API_TOKEN: accessToken,
},
})
return response.json()
},
async getBoardHierarchy(accessToken: string, boardId: string) {
const response = await page.request.get(`/api/v1/board/${boardId}/hierarchy`, {
headers: {
API_TOKEN: accessToken,
},
})
return response.json()
},
async getBoardHistory(accessToken: string, boardId: string) {
const response = await page.request.get(`/api/v1/board/${boardId}/history`, {
headers: {
API_TOKEN: accessToken,
},
})
return response.json()
},
async getBoardCsv(accessToken: string, boardId: string) {
const response = await page.request.get(`/api/v1/board/${boardId}/csv`, {
headers: {
API_TOKEN: accessToken,
},
})
return response.text()
},
async createNote(accessToken: string, boardId: string, text: string, attributes?: object) {
return await test.step("Add item " + text, async () => {
const response = await page.request.post(`/api/v1/board/${boardId}/item`, {
data: {
type: "note",
text,
color: "#000000",
...attributes,
},
headers: {
API_TOKEN: accessToken,
},
})
expect(response.status()).toEqual(200)
return await response.json()
})
},
async createBoard(data: any) {
const { id, accessToken } = await test.step("Create board", async () => {
const response = await page.request.post("/api/v1/board", {
data,
})
return await response.json()
})
return { id, accessToken }
},
async updateBoard(accessToken: string, boardId: string, data: any) {
await test.step("Update board", async () => {
const response = await page.request.put(`/api/v1/board/${boardId}`, {
data,
headers: {
API_TOKEN: accessToken,
},
})
expect(response.status()).toEqual(200)
})
},
async updateItem(accessToken: string, boardId: string, itemId: string, data: any) {
await test.step("Update item", async () => {
const response = await page.request.put(`/api/v1/board/${boardId}/item/${itemId}`, {
data,
headers: {
API_TOKEN: accessToken,
},
})
expect(response.status()).toEqual(200)
})
},
}
}

View File

@@ -0,0 +1,375 @@
import { Browser, Locator, Page, expect, selectors, test } from "@playwright/test"
import { DashboardPage, navigateToDashboard } from "./DashboardPage"
import { sleep } from "../../../common/src/sleep"
import { assertNotNull } from "../../../common/src/assertNotNull"
export async function navigateToBoard(page: Page, browser: Browser, boardId: string) {
selectors.setTestIdAttribute("data-test")
await page.goto("http://localhost:1337/b/" + boardId)
return BoardPage(page, browser)
}
export type NewBoardOptions = Partial<{
boardName: string
useCRDT: boolean
}>
export const navigateToNewBoard = (page: Page, browser: Browser, options?: NewBoardOptions) =>
test.step("Create new board", async () => {
const dashboard = await navigateToDashboard(page, browser)
return await dashboard.createNewBoard(options)
})
export const semiUniqueId = () => {
const now = String(Date.now())
return now.substring(now.length - 5)
}
export type BoardPage = ReturnType<typeof BoardPage>
export function BoardPage(page: Page, browser: Browser) {
const board = page.locator(".online .board")
const boardName = page.locator("#board-name").locator(`[contentEditable]`)
const newNoteOnPalette = page.getByTestId("palette-new-note")
const newTextOnPalette = page.getByTestId("palette-new-text")
const newContainerOnPalette = page.getByTestId("palette-new-container")
const scrollContainer = page.locator(`div [class="scroll-container"]`)
async function waitForThrottle() {
await sleep(50) // for UI throttling to take effect
}
async function moveMouseTo(newPos: { x: number; y: number }) {
const clientPos = await itemToClientPos(newPos)
await page.mouse.move(clientPos.clientX, clientPos.clientY, { steps: 10 })
await waitForThrottle()
}
async function itemToClientPos(itemPos: { x: number; y: number }) {
const scrollContainer = page.locator(`div [class="scroll-container"]`)
const scPos = assertNotNull(await scrollContainer.boundingBox())
return { clientX: itemPos.x + scPos.x, clientY: itemPos.y + scPos.y }
}
async function clientToElementPos(clientPos: { clientX: number; clientY: number }) {
const scrollContainer = page.locator(`div [class="scroll-container"]`)
const scPos = assertNotNull(await scrollContainer.boundingBox())
return { x: Math.round(clientPos.clientX - scPos.x), y: Math.round(clientPos.clientY - scPos.y) }
}
async function getElementPosition(item: Locator) {
const scPos = assertNotNull(await scrollContainer.boundingBox())
const clientPos = assertNotNull(await item.boundingBox())
return await clientToElementPos({
clientX: clientPos.x + clientPos.width / 2,
clientY: clientPos.y + clientPos.height / 2,
})
}
async function getElementSize(item: Locator) {
const { width, height } = assertNotNull(await item.boundingBox())
return { width, height }
}
async function createNew(paletteItem: Locator, x: number, y: number) {
await expect(paletteItem).toBeVisible()
await paletteItem.dispatchEvent("dragstart")
await paletteItem.dispatchEvent("dragover")
await moveMouseTo({ x, y })
await paletteItem.dispatchEvent("dragend")
}
async function dragElementOnBoard(element: Locator, x: number, y: number) {
return await test.step(`Drag item to (${x}, ${y})`, async () => {
const itemPos = await getElementPosition(element)
await moveMouseTo(itemPos)
await element.dispatchEvent("dragstart", await itemToClientPos(itemPos))
await element.dispatchEvent("drag")
page.locator(`.online .board`).dispatchEvent("dragover", await itemToClientPos(itemPos))
await moveMouseTo({ x, y })
page.locator(`.online .board`).dispatchEvent("dragover", await itemToClientPos({ x, y }))
await waitForThrottle()
await element.dispatchEvent("drag")
await element.dispatchEvent("dragend")
})
}
async function selectText(el: Locator, text: string) {
await test.step("Select text " + text, async () => {
// below code selects the given word from the line. text is the word I want to //select
await el.evaluate((element, text: string) => {
const selection = window.getSelection()!
const content = (element as HTMLElement).innerText
const range = document.createRange()
const index = content.indexOf(text)
if (index === -1) {
throw Error(`Text ${text} not found in ${content}`)
}
const textNode = element.firstChild!
console.log("Textnode", textNode.textContent)
range.setStart(textNode, index)
console.log("Length", (textNode.textContent as any).length)
range.setEnd(textNode, index + text.length)
selection.removeAllRanges()
selection.addRange(range)
}, text)
})
}
async function selectAll(el: Locator) {
const textToSelect = (await el.textContent()) ?? ""
selectText(el, textToSelect)
}
return {
page,
board,
scrollContainer,
newNoteOnPalette,
newTextOnPalette,
newContainerOnPalette,
cloneButton: page.locator(".tool.duplicate"),
getBoardId() {
return assertNotNull(page.url().split("/").pop())
},
async assertBoardName(name: string) {
await expect(boardName).toHaveText(name)
},
async getBoardName() {
return assertNotNull(await boardName.textContent())
},
async renameBoard(name: string) {
await test.step("Rename board", async () => {
await boardName.click()
await selectAll(boardName)
await boardName.pressSequentially(name)
await boardName.press("Enter")
await this.assertBoardName(name)
})
},
async assertStatusMessage(message: string) {
await expect(page.locator(".board-status-message")).toHaveText(message)
},
async cloneBoard() {
await page.getByTitle("Make a copy").click()
},
async goToDashBoard() {
await page.getByRole("link", { name: "All boards" }).click()
return DashboardPage(page, browser)
},
async createNoteWithText(x: number, y: number, text: string) {
return await test.step("Create note " + text, async () => {
await createNew(this.newNoteOnPalette, x, y)
const elt = page.locator(".note.selected .editable")
await expect(elt).toBeVisible()
await elt.fill(text)
await page.keyboard.press("Escape")
await expect(this.getNote(text)).toBeVisible()
const result = this.getNote(text)
await expect(result).toHaveText(text)
return result
})
},
async createText(x: number, y: number, text: string) {
return await test.step("Create text " + text, async () => {
await createNew(this.newTextOnPalette, x, y)
await this.getText("HELLO")
.locator(".text")
.click({ position: { x: 5, y: 5 } })
await page.keyboard.press("Delete")
await page.keyboard.press("Delete")
await page.keyboard.press("Delete")
await page.keyboard.press("Delete")
await page.keyboard.press("Delete")
await page.keyboard.type(`${text}`)
await expect(this.getText(text)).toBeVisible()
const result = this.getText(text)
await expect(result).toHaveText(text)
return result
})
},
async createArea(x: number, y: number, text: string) {
return await test.step("Create area " + text, async () => {
await createNew(this.newContainerOnPalette, x, y)
await this.getArea("Unnamed area").locator(".text").dblclick()
await page.locator("*:focus").fill(text)
await expect(this.getArea(text)).toBeVisible()
const result = this.getArea(text)
await expect(result).toHaveText(text)
return result
})
},
async dragItem(item: Locator, x: number, y: number) {
await dragElementOnBoard(item, x, y)
},
async dragSelectionBottomCorner(x: number, y: number) {
const bottomCorner = board.locator(".corner-resize-drag.bottom.right")
await dragElementOnBoard(bottomCorner, x, y)
},
getNote(name: string) {
return page.locator(`.board > .note`).filter({ hasText: name })
},
getText(name: string) {
return page.locator(`.board > .text`).filter({ hasText: name })
},
getArea(name: string) {
return page.locator(`.board > .container`).filter({ hasText: name })
},
async assertItemPosition(item: Locator, x: number, y: number) {
return await test.step(`Assert item position at (${x}, ${y})`, async () => {
const pos = await getElementPosition(item)
expect(pos.x).toBeGreaterThan(x - 5)
expect(pos.x).toBeLessThan(x + 5)
expect(pos.y).toBeGreaterThan(y - 5)
expect(pos.y).toBeLessThan(y + 5)
})
},
async assertItemSize(item: Locator, width: number, height: number) {
return await test.step(`Assert item size (${width}, ${height})`, async () => {
const size = await getElementSize(item)
expect(size.width).toBeGreaterThan(width - 5)
expect(size.width).toBeLessThan(width + 5)
expect(size.height).toBeGreaterThan(height - 5)
expect(size.height).toBeLessThan(height + 5)
})
},
async getItemPosition(item: Locator) {
return await getElementPosition(item)
},
async assertItemColor(item: Locator, color: string) {
await expect(item.locator(".shape")).toHaveCSS("background-color", color)
},
async changeItemText(item: Locator, text: string) {
await item.click()
await item.locator(".text").dblclick()
await page.keyboard.type(text)
},
async assertSelected(item: Locator, selected: boolean = true) {
if (selected) {
await expect(item).toHaveClass(/selected/)
} else {
await expect(item).not.toHaveClass(/selected/)
}
},
async assertLocked(item: Locator, locked: boolean = true) {
if (locked) {
await expect(item).toHaveClass(/locked/)
} else {
await expect(item).not.toHaveClass(/locked/)
}
},
async clickOnBoard(position: { x: number; y: number }) {
await scrollContainer.click({ position })
},
async dragOnBoard(
from: { x: number; y: number },
to: { x: number; y: number },
options: { altKey?: boolean } = {},
) {
const startDrag: DragEventInit = { ...(await itemToClientPos(from)), ...options }
const endDrag: DragEventInit = { ...(await itemToClientPos(to)), ...options }
await moveMouseTo(from)
await this.board.dispatchEvent("dragstart", startDrag)
await this.board.dispatchEvent("drag", startDrag)
await this.board.dispatchEvent("dragover", startDrag)
await this.board.dispatchEvent("dragover", endDrag)
await waitForThrottle()
await this.board.dispatchEvent("drag", endDrag)
await this.board.dispatchEvent("dragend", endDrag)
},
async selectItems(...items: Locator[]) {
for (let i = 0; i < items.length; i++) {
await this.assertLocked(items[i], false)
if (i === 0) {
await items[i].click({ position: { x: 5, y: 5 } })
} else {
await items[i].click({ modifiers: ["Shift"], position: { x: 5, y: 5 } })
}
await this.assertSelected(items[i], true)
}
},
async setNickname(nickname: string) {
await page
.locator(".user-info .icon")
.or(page.locator(".user-info .nickname"))
.first()
.click({ force: true })
await page.locator(".user-info .nickname input").fill(nickname)
await page.locator(".user-info button").click()
},
contextMenu: ContextMenu(page),
async deleteIndexedDb() {
await page.evaluate(async (boardId) => {
const request = indexedDB.deleteDatabase(`b/${boardId}`)
await new Promise((resolve, reject) => {
request.onsuccess = resolve
request.onerror = reject
})
}, this.getBoardId())
expect
.poll(async () => {
try {
const databases = await page.evaluate(async (boardId) => {
return await indexedDB.databases()
}, this.getBoardId())
return databases
} catch (e) {
console.warn("indexedDB.databases call failed. This is not supported in Firefox")
return []
}
})
.not.toContain(expect.objectContaining({ name: `b/${this.getBoardId()}` }))
},
async openBoardInNewBrowser() {
const boardId = this.getBoardId()
const newBoard = await navigateToBoard(await (await browser.newContext()).newPage(), browser, boardId)
return newBoard
},
async setOfflineMode(offline: boolean) {
await page.evaluate((offline) => {
;(window as any).forceOffline.set(offline)
}, offline)
if (offline) {
await expect(page.locator(".offline-status")).toBeVisible()
} else {
await expect(page.locator(".offline-status")).not.toBeVisible()
}
},
}
}
function ContextMenu(page: Page) {
const contextMenu = page.locator(".context-menu")
return {
async openColorsAndShapes() {
await contextMenu.locator(".colors-shapes .icon").click()
return {
async selectColor(color: string) {
await page.locator(`.submenu .colors .icon.${color}`).click()
},
}
},
async openHorizontalAlign() {
await contextMenu.locator(".icon-group.align .icon").first().click()
const submenu = page.locator(`.submenu.alignment.x`)
return {
left: submenu.getByTitle("Align left"),
center: submenu.getByTitle("Align center"),
right: submenu.getByTitle("Align right"),
}
},
async openVerticalAlign() {
await contextMenu.locator(".icon-group.align .icon").nth(1).click()
const submenu = page.locator(`.submenu.alignment.y`)
return {
top: submenu.getByTitle("Align top"),
middle: submenu.getByTitle("Align middle"),
bottom: submenu.getByTitle("Align bottom"),
}
},
async toggleContentsHidden() {
await contextMenu.locator(".visibility .icon").click()
},
}
}

View File

@@ -0,0 +1,37 @@
import { Browser, Page, selectors, test } from "@playwright/test"
import { BoardPage, semiUniqueId, NewBoardOptions } from "./BoardPage"
export async function navigateToDashboard(page: Page, browser: Browser) {
selectors.setTestIdAttribute("data-test")
await page.goto("http://localhost:1337")
return DashboardPage(page, browser)
}
export function DashboardPage(page: Page, browser: Browser) {
return {
async createNewBoard(options?: NewBoardOptions) {
return await test.step("Create new board", async () => {
const name = options?.boardName ?? `Test board ${semiUniqueId()}`
const useCRDT = options?.useCRDT ?? true
await page.getByPlaceholder("Enter board name").fill(name)
if (useCRDT) {
await page.getByText("use collaborative text editor").click()
}
await page.getByRole("button", { name: "Create" }).click()
const board = BoardPage(page, browser)
// TODO: this is flaky in at least "Switching between boards"
await board.assertBoardName(name)
return board
})
},
async goToBoard(name: string) {
await page.locator(".recent-boards li").filter({ hasText: name }).first().click()
return BoardPage(page, browser)
},
async goToTutorialBoard() {
await page.getByText("Tutorial Board").click()
return BoardPage(page, browser)
},
}
}

View File

@@ -0,0 +1,139 @@
import { Page, expect, test } from "@playwright/test"
import { sleep } from "../../../common/src/sleep"
import { navigateToBoard } from "../pages/BoardPage"
async function loginAsTester(page: Page) {
await test.step("Login as tester", async () => {
await page.request.get("/test-callback")
})
}
async function logout(page: Page) {
await test.step("Logout", async () => {
await page.request.get("/logout")
})
}
// TODO: test creating accessPolicy through UI
// TODO: test changing accessPolicy through UI
test.describe("Board access policy", () => {
test("Create board with accessPolicy using API", async ({ page, browser }) => {
await test.step("With empty accessPolicy", async () => {
const board = await test.step("Create board and navigate", async () => {
const response = await page.request.post("/api/v1/board", {
data: {
name: "API restricted board",
accessPolicy: {
allowList: [],
},
},
})
const { id, accessToken } = await response.json()
return await navigateToBoard(page, browser, id)
})
await test.step("Verify no UI access", async () => {
await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.")
await loginAsTester(page)
await page.reload()
await board.assertStatusMessage("Sorry, access denied. Click here to sign in with another account.")
await logout(page)
})
})
await test.step("With non-empty accessPolicy", async () => {
const board = await test.step("Create board and navigate", async () => {
const response = await page.request.post("/api/v1/board", {
data: {
name: "API restricted board",
accessPolicy: {
allowList: [{ email: "ourboardtester@test.com" }],
},
},
})
const { id, accessToken } = await response.json()
return await navigateToBoard(page, browser, id)
})
await test.step("Verify restricted access", async () => {
await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.")
await loginAsTester(page)
await page.reload()
await board.assertBoardName("API restricted board")
})
await test.step("Rename board through UI", async () => {
await board.renameBoard("API restricted board renamed")
await sleep(1000)
await page.reload()
await board.assertBoardName("API restricted board renamed")
})
await test.step("Verify restricted access", async () => {
await logout(page)
await page.reload()
await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.")
})
})
})
test("Create restricted board with public read access using API", async ({ page, browser }) => {
const { id, accessToken } = await test.step("Create board and navigate", async () => {
const response = await page.request.post("/api/v1/board", {
data: {
name: "API board with public read",
accessPolicy: {
publicRead: true,
allowList: [{ email: "ourboardtester@test.com" }],
},
crdt: true,
},
})
return await response.json()
})
await loginAsTester(page)
const board = await navigateToBoard(page, browser, id)
await test.step("Create content as authorized user", async () => {
await board.assertBoardName("API board with public read")
await board.createNoteWithText(100, 200, "Test note")
await board.createArea(100, 400, "Test area with CRDT")
})
await test.step("Verify read-only access", async () => {
await logout(page)
await page.reload()
await expect(board.getNote("Test note")).toBeVisible()
await expect(board.getArea("Test area with CRDT")).toBeVisible()
await board.changeItemText(board.getNote("Test note"), "Updated note")
await board.changeItemText(board.getArea("Test area with CRDT"), "I should not be able to do this")
await expect(board.getArea("I should not be able to do this")).not.toBeVisible()
})
await test.step("Remove public read access", async () => {
const newAccessPolicy = {
allowList: [{ email: "ourboardtester@test.com" }],
publicRead: false,
}
const response = await page.request.put(`/api/v1/board/${id}`, {
data: {
name: "Updated board name",
accessPolicy: newAccessPolicy,
},
headers: {
API_TOKEN: accessToken,
},
})
expect(response.status()).toEqual(200)
await page.reload()
await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.")
})
})
})

View File

@@ -0,0 +1,221 @@
import { Page, expect, test } from "@playwright/test"
import { Note } from "../../../common/src/domain"
import { sleep } from "../../../common/src/sleep"
import { navigateToBoard, semiUniqueId } from "../pages/BoardPage"
import { BoardApi } from "../pages/BoardApi"
test.describe("API endpoints", () => {
test("Create and update board", async ({ page, browser }) => {
const Api = BoardApi(page)
const { id, accessToken } = await Api.createBoard({ name: "API test board" })
const board = await navigateToBoard(page, browser, id)
await test.step("Check board name", async () => {
await board.assertBoardName("API test board")
const userPageNoteText = `note-${semiUniqueId()}`
await board.createNoteWithText(100, 200, userPageNoteText)
await board.createArea(100, 400, "API notes")
await board.createArea(550, 400, "More API notes")
})
await test.step("Set board name", async () => {
await Api.updateBoard(accessToken, id, {
name: "Updated board name",
})
await board.assertBoardName("Updated board name")
})
const item = await Api.createNote(accessToken, id, "API note", { container: "API notes" })
await expect(board.getNote("API note")).toBeVisible()
await test.step("Update item", async () => {
await Api.updateItem(accessToken, id, item.id, {
type: "note",
text: "Updated item",
color: "#000000",
})
await expect(board.getNote("Updated item")).toBeVisible()
})
await test.step("Change item container", async () => {
await board.assertItemPosition(board.getNote("Updated item"), 163, 460)
await Api.updateItem(accessToken, id, item.id, {
type: "note",
text: "Updated item",
color: "#000000",
container: "More API notes",
})
await sleep(1000)
await board.assertItemPosition(board.getNote("Updated item"), 613, 460)
})
const itemWithCoordinates = await test.step("Create new item with position and size", async () => {
const itemWithCoordinates = await Api.createNote(accessToken, id, "API New note", {
x: 100,
y: 200,
width: 15,
height: 30,
})
const noteElement = board.getNote("API New note")
await expect(noteElement).toBeVisible()
const style = await noteElement.getAttribute("style")
expect(style).toContain("transform: translate(100em, 200em)")
expect(style).toContain("width: 15em")
expect(style).toContain("height: 30em")
return itemWithCoordinates
})
await test.step("Update item position and size", async () => {
await Api.updateItem(accessToken, id, itemWithCoordinates.id, {
x: 20,
y: 20,
type: "note",
text: "Updated new item",
color: "#000000",
width: 10,
height: 10,
})
const noteElement = board.getNote("Updated new item")
await expect(noteElement).toBeVisible()
const style = await noteElement.getAttribute("style")
expect(style).toContain("transform: translate(20em, 20em)")
expect(style).toContain("width: 10em")
expect(style).toContain("height: 10em")
})
await test.step("Get board state", async () => {
expect
.poll(async () => await Api.getBoard(accessToken, id))
.toEqual({
board: {
id,
name: "Updated board name",
width: 800,
height: 600,
serial: expect.anything(),
connections: [],
items: expect.anything(),
},
})
const content = await Api.getBoard(accessToken, id)
expect(Object.values(content.board.items)).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "container",
text: "API notes",
}),
expect.objectContaining({
type: "note",
text: "Updated item",
}),
]),
)
})
await test.step("Get board state hierarchy", async () => {
expect
.poll(async () => await Api.getBoardHierarchy(accessToken, id))
.toEqual({
board: {
id,
name: "Updated board name",
width: 800,
height: 600,
serial: expect.anything(),
connections: [],
items: expect.anything(),
},
})
})
await test.step("Get board history", async () => {
const history = (await Api.getBoardHistory(accessToken, id)).history
expect(history.length).toBeGreaterThan(0)
})
await test.step("Get board as CSV", async () => {
// CSV only includes items that are in containers
expect(await Api.getBoardCsv(accessToken, id)).toEqual("More API notes,Updated item\n")
})
await test.step("Set accessPolicy", async () => {
await Api.updateBoard(accessToken, id, {
name: "Updated board name",
accessPolicy: {
allowList: [],
publicRead: false,
publicWrite: false,
},
})
await page.reload()
await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.")
expect((await Api.getBoard(accessToken, id)).board.accessPolicy).toEqual({
allowList: [],
publicRead: false,
publicWrite: false,
})
})
await test.step("Update accessPolicy", async () => {
const newAccessPolicy = {
allowList: [{ email: "ourboardtester@test.com" }],
publicRead: true,
publicWrite: true,
}
await Api.updateBoard(accessToken, id, {
name: "Updated board name",
accessPolicy: newAccessPolicy,
})
await page.reload()
await expect(board.getNote("Updated item")).toBeVisible()
expect((await Api.getBoard(accessToken, id)).board.accessPolicy).toEqual(newAccessPolicy)
})
})
test("Get board contents with CRDT text", async ({ page, browser }) => {
const Api = BoardApi(page)
const { id, accessToken } = await Api.createBoard({ name: "API test board", crdt: true })
const board = await navigateToBoard(page, browser, id)
await board.createText(100, 200, "CRDT text")
await board.createNoteWithText(100, 400, "Simple note")
await test.step("Get board state", async () => {
const content = await Api.getBoard(accessToken, id)
expect(Object.values(content.board.items)).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "text",
text: "CRDT text",
textAsDelta: [{ insert: "CRDT text" }],
}),
expect.objectContaining({
type: "note",
text: "Simple note",
}),
]),
)
expect((Object.values(content.board.items)[1] as Note).textAsDelta).toBeUndefined()
})
await test.step("Get board hierarchy", async () => {
const content = await Api.getBoardHierarchy(accessToken, id)
expect(Object.values(content.board.items)).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "text",
text: "CRDT text",
textAsDelta: [{ insert: "CRDT text" }],
}),
]),
)
})
})
})

View File

@@ -0,0 +1,333 @@
import { Browser, Page, expect, test } from "@playwright/test"
import { sleep } from "../../../common/src/sleep"
import { BoardPage, navigateToNewBoard, semiUniqueId } from "../pages/BoardPage"
import { BoardApi } from "../pages/BoardApi"
test.describe("Basic board functionality", () => {
test("Create note by dragging from palette", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const userPageNoteText = `note-${semiUniqueId()}`
await board.createNoteWithText(100, 200, userPageNoteText)
})
testWithBothBoardTypes("Create text by dragging from palette", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const userPageNoteText = `note-${semiUniqueId()}`
await board.createText(100, 200, userPageNoteText)
})
testWithBothBoardTypes("Create container by dragging from palette", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const userPageNoteText = `note-${semiUniqueId()}`
await board.createArea(100, 200, userPageNoteText)
})
test("Create note by double clicking on board", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
await board.board.dblclick({ position: { x: 200, y: 200 } })
await expect(board.getNote("HELLO")).toBeVisible()
await test.step("Also inside an Area", async () => {
await board.createArea(300, 200, "Container")
await board.board.dblclick({ position: { x: 350, y: 250 } })
await expect(board.getNote("HELLO")).toHaveCount(2)
})
})
testWithBothBoardTypes("Drag notes", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
const semigroups = await board.createNoteWithText(200, 200, "Semigroups")
await test.step("Drag to new position", async () => {
await board.dragItem(monoids, 300, 300)
await board.assertItemPosition(monoids, 300, 300)
})
const area = await board.createArea(450, 100, "Container")
await test.step("Drag multiple items", async () => {
await board.selectItems(monoids, semigroups)
await board.dragItem(monoids, 600, 300)
await board.assertItemPosition(monoids, 600, 300)
await board.assertItemPosition(semigroups, 500, 200)
})
await test.step("Drag area to move contained items", async () => {
await board.dragItem(area, 300, 300)
await board.assertItemPosition(area, 300, 300)
await board.assertItemPosition(monoids, 240, 360)
await board.assertItemPosition(semigroups, 140, 260)
})
})
// TODO: test creating and modifying connections
test("Change note color", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
const colorsAndShapes = await board.contextMenu.openColorsAndShapes()
await colorsAndShapes.selectColor("pink")
await board.assertItemColor(monoids, "rgb(253, 196, 231)")
})
test.describe("Duplicate items", () => {
testWithBothBoardTypes("Duplicate text by Ctrl+D", async ({ board }) => {
const monoids = await board.createText(100, 200, "Monoids")
const functors = await board.createNoteWithText(300, 200, "Functors")
await board.selectItems(monoids, functors)
await monoids.press("Control+d")
await expect(monoids).toHaveCount(2)
await expect(functors).toHaveCount(2)
})
testWithBothBoardTypes("Duplicate a container with child items", async ({ board }) => {
const container = await board.createArea(100, 200, "Container")
const text = await board.createText(150, 250, "text")
await board.selectItems(container)
await container.press("Control+d")
const clonedText = board.getText("text").nth(1)
await expect(clonedText).toHaveText("text")
})
test("Duplicate deeper hierarchy", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const container = await board.createArea(100, 200, "Top")
await board.dragSelectionBottomCorner(550, 550)
const container2 = await board.createArea(110, 220, "Middle")
const text = await board.createText(150, 250, "Bottom")
await board.selectItems(container)
await container.press("Control+d")
const clonedText = board.getText("Bottom").nth(1)
await expect(clonedText).toHaveText("Bottom")
})
})
test.skip("Copy, paste and cut", async ({ page, browser }) => {
// TODO: not sure how to trigger native copy, paste events
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
await page.keyboard.press("Control+c", {})
await board.clickOnBoard({ x: 500, y: 300 })
await page.keyboard.press("Control+v")
await board.changeItemText(board.getNote("Monoids"), "Monads")
await expect(board.getNote("Monoids")).toBeVisible()
await expect(board.getNote("Monads")).toBeVisible()
})
test("Move items with arrow keys", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
const origPos = await board.getItemPosition(monoids)
await test.step("Normally", async () => {
await page.keyboard.press("ArrowRight")
expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x + 14, y: origPos.y })
})
await test.step("Faster with shift", async () => {
await page.keyboard.press("Shift+ArrowLeft")
expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x - 125, y: origPos.y })
})
await test.step("Slower with alt", async () => {
await page.keyboard.press("Alt+ArrowDown")
expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x - 125, y: origPos.y + 1 })
})
})
test("Delete notes", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
await test.step("With delete key", async () => {
await page.keyboard.press("Delete")
await expect(monoids).not.toBeVisible()
})
await test.step("With backspace key", async () => {
const functors = await board.createNoteWithText(100, 200, "Functors")
await page.keyboard.press("Delete")
await expect(functors).not.toBeVisible()
})
})
test("Align notes", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const n1 = await board.createNoteWithText(250, 120, "ALIGN")
const n2 = await board.createNoteWithText(450, 100, "ALL")
const n3 = await board.createNoteWithText(320, 250, "THESE")
await board.createNoteWithText(300, 450, "BUT NOT THIS")
await board.selectItems(n1, n2, n3)
const originalCoordinates = await Promise.all([n1, n2, n3].map((n) => board.getItemPosition(n)))
await (await board.contextMenu.openHorizontalAlign()).left.click()
const newCoordinates = await Promise.all([n1, n2, n3].map((n) => board.getItemPosition(n)))
const expectedX = originalCoordinates[0].x
for (let i = 1; i < newCoordinates.length; i++) {
expect(newCoordinates[i].x).toEqual(expectedX)
}
})
test("Edit note text", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
const semigroups = await board.createNoteWithText(200, 200, "Semigroups")
await test.step("Change text", async () => {
await board.changeItemText(board.getNote("Monoids"), "Monads")
await expect(board.getNote("Monads")).toBeVisible()
})
await test.step("Check persistence", async () => {
await sleep(1000) // Time for persistence
await page.reload()
await expect(board.getNote("Monads")).toBeVisible()
await expect(semigroups).toBeVisible()
})
await test.step("Check with new session", async () => {
const newBoard = await board.openBoardInNewBrowser()
await newBoard.setNickname("User 2")
await expect(newBoard.getNote("Monads")).toBeVisible()
})
})
testWithBothBoardTypes("Edit area text", async ({ board, page }) => {
const monoids = await board.createArea(100, 200, "Monoids")
const semigroups = await board.createArea(500, 200, "Semigroups")
await test.step("Change text", async () => {
await board.changeItemText(board.getArea("Monoids"), "Monads")
await expect(board.getArea("Monads")).toBeVisible()
})
await test.step("Check persistence", async () => {
await sleep(1000) // Time for persistence
await page.reload()
await expect(board.getArea("Monads")).toBeVisible()
await expect(semigroups).toBeVisible()
})
await test.step("Check with new session", async () => {
await board.deleteIndexedDb()
const newBoard = await board.openBoardInNewBrowser()
await newBoard.setNickname("User 2")
await expect(newBoard.getArea("Monads")).toBeVisible()
})
})
/*
testWithBothBoardTypes("Simulate typing lot of text", async ({ board, page }) => {
const textArea = await board.createText(100, 200, "Initial text")
await board.selectItems(textArea)
await board.dragSelectionBottomCorner(600, 600)
const item = board.board.locator(".text").first()
await board.changeItemText(item, "New chapter in life\n")
await test.step("Change text", async () => {
for (let i = 0; i < 10; i++) {
await item.pressSequentially("New chapter in life\n")
await sleep(100)
}
})
// This is just to check stored data size - compare the legacy approach vs CRDT storage.
//
// Results:
// Legacy: 2159b JSON, 1 event row, 45 events
// CRDT: 904b CRDT + 740b JSON, 1 event row, 2 events
// SQL query: select coalesce(sum(pg_column_size(crdt_update)), 0) as crdt_size, sum(pg_column_size(events)) as json_size, count(*) as rows, sum(last_serial - first_serial - 1) as event_count from board_event where board_id ='2bc90b97-8304-4cd6-8954-b87878db6ff3';
})
*/
testWithBothBoardTypes("Hide area contents", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const area = await board.createArea(100, 200, "Area")
const text = await board.createNoteWithText(150, 250, "text")
await board.selectItems(area)
await board.contextMenu.toggleContentsHidden()
await expect(text).not.toBeVisible()
await test.step("Check persistence", async () => {
await sleep(1000) // Time for persistence
await page.reload()
await expect(text).not.toBeVisible()
})
await test.step("Check with new session", async () => {
await board.deleteIndexedDb()
const newBoard = await board.openBoardInNewBrowser()
await newBoard.setNickname("User 2")
await expect(newBoard.getNote("text")).not.toBeVisible()
})
})
test("Resize notes", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(100, 200, "Monoids")
await test.step("Can drag to resize items", async () => {
await board.selectItems(monoids)
await board.dragSelectionBottomCorner(550, 550)
await board.assertItemSize(monoids, 380, 380)
})
})
test("Select notes", async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser)
const monoids = await board.createNoteWithText(150, 200, "Monoids")
const semigroups = await board.createNoteWithText(250, 200, "SemiGroups")
async function resetSelection() {
await board.clickOnBoard({ x: 100, y: 100 })
await board.assertSelected(monoids, false)
}
await test.step("Can select note by dragging on board with ALT pressed", async () => {
await resetSelection()
await board.dragOnBoard({ x: 100, y: 100 }, { x: 300, y: 300 }, { altKey: true })
await board.assertSelected(monoids)
})
await test.step("Can select notes with SHIFT key", async () => {
await resetSelection()
await board.selectItems(monoids, semigroups)
await board.assertSelected(monoids)
await board.assertSelected(semigroups)
})
})
testWithBothBoardTypes("Clone the board", async ({ board, page }) => {
const semigroups = await board.createArea(500, 200, "Semigroups")
const functors = await board.createNoteWithText(200, 200, "Functors")
await sleep(1000)
await board.cloneBoard()
await board.assertBoardName("Clone the board copy")
await expect(semigroups).toBeVisible()
await expect(functors).toBeVisible()
await test.step("Check persistence", async () => {
await page.reload()
await expect(semigroups).toBeVisible()
})
await test.step("Check with new session", async () => {
await board.deleteIndexedDb()
const newBoard = await board.openBoardInNewBrowser()
await newBoard.setNickname("User 2")
await expect(newBoard.getArea("Semigroups")).toBeVisible()
})
})
})
function testWithBothBoardTypes(
name: string,
runTest: (options: { board: BoardPage; page: Page; browser: Browser }) => Promise<void>,
) {
test(`${name}`, async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser, { boardName: name })
await runTest({ board, page, browser })
})
test(`${name} (legacy board)`, async ({ page, browser }) => {
const board = await navigateToNewBoard(page, browser, { useCRDT: false, boardName: name })
await runTest({ board, page, browser })
})
}

View File

@@ -0,0 +1,147 @@
import { Browser, Page, expect, test } from "@playwright/test"
import { navigateToNewBoard, semiUniqueId } from "../pages/BoardPage"
import { sleep } from "../../../common/src/sleep"
test.describe("Two simultaneous users", () => {
test("Two anonymous users can see each other notes", async ({ page, browser }) => {
const { user1Page: userPage, user2Page } = await createBoardWithTwoUsers(page, browser)
// create 2 notes, one on each page
const userPageNoteText = `note-${semiUniqueId()}`
await userPage.createNoteWithText(100, 200, userPageNoteText)
const anotherUserPageNoteText = `another-${semiUniqueId()}`
await user2Page.createNoteWithText(500, 200, anotherUserPageNoteText)
await expect(user2Page.getNote(userPageNoteText)).toBeVisible()
await expect(userPage.getNote(anotherUserPageNoteText)).toBeVisible()
})
test("Change board name", async ({ page, browser }) => {
const { user1Page: userPage, user2Page } = await createBoardWithTwoUsers(page, browser)
// create 2 notes, one on each page
await userPage.renameBoard("Renamed board")
await user2Page.assertBoardName("Renamed board")
})
const onTopPart = { position: { x: 9, y: 15 } } as const
test("Users can collaboratively edit a text area", async ({ page, browser }) => {
const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)
await user1Page.createArea(100, 200, "initialText")
await test.step("Both users edit text", async () => {
await user1Page.getArea("initialText").click(onTopPart)
await user2Page.getArea("initialText").press("ArrowDown")
await user1Page.getArea("initialText").pressSequentially("User1Text")
await user2Page.getArea("initialText").dblclick(onTopPart)
await user2Page.getArea("initialText").press("ArrowDown")
await user2Page.getArea("initialText").pressSequentially("User2Text")
await expect(user1Page.getArea("User1Text")).toBeVisible()
await expect(user2Page.getArea("User1Text")).toBeVisible()
await expect(user1Page.getArea("User2Text")).toBeVisible()
await expect(user2Page.getArea("User2Text")).toBeVisible()
})
await test.step("User 1 duplicates text area", async () => {
await user1Page.cloneButton.click()
})
await test.step("Text changes to new area do not affect old area", async () => {
const oldArea = user1Page.getArea("User2Text").first()
const newArea = user1Page.getArea("User2Text").last()
await newArea.press("Escape")
await user1Page.dragItem(newArea, 300, 300)
await newArea.dblclick(onTopPart)
await newArea.press("ArrowDown")
await newArea.pressSequentially("NewText")
await expect(newArea).toContainText("NewText")
await user1Page.assertSelected(newArea, true)
await newArea.press("Escape")
await newArea.press("Escape")
await user1Page.assertSelected(newArea, false)
await expect(oldArea).not.toContainText("NewText")
await expect(user2Page.getArea("NewText")).toBeVisible()
})
await test.step("Deleting the new area does not affect the old area", async () => {
const newAreaUser2 = user2Page.getArea("NewText")
await user2Page.assertSelected(newAreaUser2, false)
await user2Page.selectItems(newAreaUser2)
await newAreaUser2.press("Delete")
await expect(user2Page.getArea("NewText")).not.toBeVisible()
await expect(user1Page.getArea("NewText")).not.toBeVisible()
await expect(user2Page.getArea("User2Text")).toBeVisible()
await expect(user1Page.getArea("User2Text")).toBeVisible()
})
})
test("Offline changes are synced on re-connection", async ({ page, browser }) => {
const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)
await user1Page.createNoteWithText(100, 400, "basetext")
await sleep(1000)
await user1Page.setOfflineMode(true)
await user2Page.setOfflineMode(true)
await sleep(1000)
await user1Page.createArea(100, 200, "user 1 offline text")
await user2Page.createArea(500, 200, "user 2 offline text")
await test.step("Verify that changes are not synced while offline", async () => {
await sleep(1000)
await expect(user1Page.getArea("user 2 offline text")).not.toBeVisible()
})
await test.step("Reload page to verify offline persistence", async () => {
await user1Page.page.reload()
await expect(user1Page.getArea("user 1 offline text")).toBeVisible()
})
await test.step("Reconnect and verify that changes are synced", async () => {
await user1Page.setOfflineMode(false)
await user2Page.setOfflineMode(false)
await sleep(1000)
await expect(user1Page.getArea("user 2 offline text")).toBeVisible()
await expect(user2Page.getArea("user 1 offline text")).toBeVisible()
})
})
test("Offline changes are synced on re-connection (first event offline)", async ({ page, browser }) => {
const user1Page = await navigateToNewBoard(page, browser, { boardName: "Collab test board" })
await user1Page.setOfflineMode(true)
await sleep(1000)
await user1Page.createArea(100, 200, "user 1 offline text")
await test.step("Reload page to verify offline persistence", async () => {
await user1Page.page.reload()
await expect(user1Page.getArea("user 1 offline text")).toBeVisible()
})
})
test("Switching between boards", async ({ page, browser }) => {
const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)
const boardName1 = await user1Page.getBoardName()
const monoids = await user1Page.createText(100, 200, "Monoids")
const dashboard = await user1Page.goToDashBoard()
await dashboard.createNewBoard({ boardName: "Another board" })
const semigroups = await user1Page.createArea(500, 200, "Semigroups")
await user1Page.goToDashBoard()
await dashboard.goToBoard(boardName1)
await expect(monoids).toBeVisible()
await user2Page.createNoteWithText(100, 400, "User 2 note")
await expect(user1Page.getNote("User 2 note")).toBeVisible()
await user2Page.createText(300, 200, "User 2 text")
await expect(user1Page.getText("User 2 text")).toBeVisible()
})
async function createBoardWithTwoUsers(page: Page, browser: Browser) {
const user1Page = await navigateToNewBoard(page, browser, { boardName: "Collab test board" })
await user1Page.setNickname("User 1")
const boardId = user1Page.getBoardId()
const user2Page = await user1Page.openBoardInNewBrowser()
await user2Page.setNickname("User 2")
await user1Page.assertBoardName("Collab test board")
await user2Page.assertBoardName("Collab test board")
return { user1Page, user2Page, boardId }
}
})

View File

@@ -0,0 +1,35 @@
import { test } from "@playwright/test"
import { navigateToBoard } from "../pages/BoardPage"
import { navigateToDashboard } from "../pages/DashboardPage"
test.describe("Dashboard", () => {
test("Creating a new board", async ({ page, browser }) => {
const dashboard = await navigateToDashboard(page, browser)
const board = await dashboard.createNewBoard({ boardName: "My new board" })
await board.assertBoardName("My new board")
await board.goToDashBoard()
await test.step("Do it again to make sure it works", async () => {
await dashboard.createNewBoard({ boardName: "My new board" })
await board.assertBoardName("My new board")
})
await test.step("Navigating to the new board by URL", async () => {
const boardId = board.getBoardId()
await board.goToDashBoard()
await navigateToBoard(page, browser, boardId)
await board.assertBoardName("My new board")
})
await test.step("Navigating to the new board from the dashboard", async () => {
await board.goToDashBoard()
await dashboard.goToBoard("My new board")
await board.assertBoardName("My new board")
})
})
test("Personal tutorial board", async ({ page, browser }) => {
const dashboard = await navigateToDashboard(page, browser)
const board = await dashboard.goToTutorialBoard()
await board.assertBoardName("My personal tutorial board")
})
})

View File

@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test"
import { navigateToBoard } from "../pages/BoardPage"
test.describe("Navigation", () => {
test("Navigation to default board by URL", async ({ page, browser }) => {
const board = await navigateToBoard(page, browser, "default")
await board.assertBoardName("Test Board")
expect(board.getBoardId()).toBe("default")
})
test("Navigation to non-existing board by URL", async ({ page, browser }) => {
const board = await navigateToBoard(page, browser, "non-existing-board-id")
await board.assertStatusMessage("Board not found. A typo, maybe?")
})
})