Spaces:
Running
Running
// Shared testing utilities. | |
import { type Locator, type Page, expect } from "@playwright/test"; | |
// Mirrors the "id" filter. | |
export function toId(x) { | |
return x.toLowerCase().replace(/[ !?,./]/g, "-"); | |
} | |
export const ROOT = "automated-tests"; | |
export class Workspace { | |
readonly page: Page; | |
name: string; | |
constructor(page: Page, workspaceName: string) { | |
this.page = page; | |
this.name = workspaceName; | |
} | |
// Starts with a brand new workspace. | |
static async empty(page: Page, workspaceName?: string): Promise<Workspace> { | |
const splash = await Splash.open(page); | |
return await splash.createWorkspace(workspaceName); | |
} | |
static async open(page: Page, workspaceName: string): Promise<Workspace> { | |
const splash = await Splash.open(page); | |
const ws = await splash.openWorkspace(workspaceName); | |
await ws.waitForNodesToLoad(); | |
await ws.expectCurrentWorkspaceIs(workspaceName); | |
return ws; | |
} | |
async getEnvs() { | |
// Return all available workspace environments | |
const envs = this.page.locator('select[name="workspace-env"] option'); | |
await expect(envs).not.toHaveCount(0); | |
return await envs.allInnerTexts(); | |
} | |
async setEnv(env: string) { | |
await this.page.locator('select[name="workspace-env"]').selectOption(env); | |
} | |
async expectCurrentWorkspaceIs(name) { | |
await expect(this.page.locator(".ws-name")).toHaveText(name); | |
} | |
async waitForNodesToLoad() { | |
// This method should be used only on non empty workspaces | |
await this.page.locator(".react-flow__nodes").waitFor(); | |
await this.page.locator(".react-flow__node").first().waitFor(); | |
} | |
async addBox(boxName) { | |
//TODO: Support passing box parameters (id, position, etc.) | |
const allBoxes = await this.getBoxes().all(); | |
if (allBoxes) { | |
// Avoid overlapping with existing nodes | |
const numNodes = allBoxes.length || 1; | |
await this.page.mouse.wheel(0, numNodes * 400); | |
await new Promise((resolve) => setTimeout(resolve, 200)); | |
} | |
// Some x,y offset, otherwise the box handle may fall outside the viewport. | |
await this.page.locator(".ws-name").click(); | |
await this.page.keyboard.press("/"); | |
await this.page.locator(".node-search").getByText(boxName).click(); | |
await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1); | |
} | |
async getCatalog() { | |
await this.page.locator(".react-flow__pane").click(); | |
const catalog = await this.page | |
.locator(".node-search .matches .search-result") | |
.allInnerTexts(); | |
// Dismiss the catalog menu | |
await this.page.keyboard.press("Escape"); | |
await expect(this.page.locator(".node-search")).not.toBeVisible(); | |
return catalog; | |
} | |
async selectBox(boxId: string) { | |
const box = this.getBox(boxId); | |
// Click on the resizer, so we don't click on any parameters by accident. | |
await box.locator(".react-flow__resize-control").click(); | |
await expect(box).toHaveClass(/selected/); | |
} | |
async deleteBoxes(boxIds: string[]) { | |
for (const boxId of boxIds) { | |
await this.selectBox(boxId); | |
await this.page.keyboard.press("Backspace"); | |
await expect(this.getBox(boxId)).not.toBeVisible(); | |
} | |
} | |
getBox(boxId: string) { | |
return this.page.locator(`[data-id="${boxId}"]`); | |
} | |
getBoxes() { | |
return this.page.locator(".react-flow__node"); | |
} | |
getBoxHandle(boxId: string, pos?: string) { | |
if (pos) { | |
return this.page.locator( | |
`[data-id="${boxId}"] [data-handlepos="${pos}"]`, | |
); | |
} | |
return this.page.getByTestId(boxId); | |
} | |
async moveBox( | |
boxId: string, | |
offset?: { offsetX: number; offsetY: number }, | |
targetPosition?: { x: number; y: number }, | |
) { | |
// Move a box around, it is a best effort operation, the exact target position may not be reached | |
const box = await this.getBox(boxId).locator(".title").boundingBox(); | |
if (!box) { | |
return; | |
} | |
const boxCenterX = box.x + box.width / 2; | |
const boxCenterY = box.y + box.height / 2; | |
await this.page.mouse.move(boxCenterX, boxCenterY); | |
await this.page.mouse.down(); | |
if (targetPosition) { | |
await this.page.mouse.move(targetPosition.x, targetPosition.y); | |
} else if (offset) { | |
// Without steps the movement is too fast and the box is not dragged. The more steps, | |
// the better the movement is captured | |
await this.page.mouse.move( | |
boxCenterX + offset.offsetX, | |
boxCenterY + offset.offsetY, | |
{ steps: 5 }, | |
); | |
} | |
await this.page.mouse.up(); | |
} | |
async connectBoxes(sourceId: string, targetId: string) { | |
const sourceHandle = this.getBoxHandle(sourceId, "right"); | |
const targetHandle = this.getBoxHandle(targetId, "left"); | |
await sourceHandle.hover(); | |
await this.page.mouse.down(); | |
await targetHandle.hover(); | |
await this.page.mouse.up(); | |
} | |
async expectErrorFree(executionWaitTime?) { | |
// TODO: Workaround, to account for workspace execution. Once | |
// we have a load indicator we can use that instead. | |
await new Promise((resolve) => | |
setTimeout(resolve, executionWaitTime ? executionWaitTime : 500), | |
); | |
await expect(this.getBoxes().locator(".error").first()).not.toBeVisible(); | |
} | |
async close() { | |
await this.page.locator('a[href="/dir/"]').click(); | |
} | |
} | |
export class Splash { | |
page: Page; | |
root: Locator; | |
constructor(page) { | |
this.page = page; | |
this.root = page.locator("#splash"); | |
} | |
// Opens the LynxKite directory browser in the root. | |
static async open(page: Page): Promise<Splash> { | |
await page.goto("/"); | |
await page.evaluate(() => { | |
window.sessionStorage.clear(); | |
window.localStorage.clear(); | |
}); | |
await page.reload(); | |
const splash = new Splash(page); | |
return splash; | |
} | |
workspace(name: string) { | |
return this.page.getByRole("link", { name: name }); | |
} | |
getEntry(name: string) { | |
return this.page.locator(".entry").filter({ hasText: name }).first(); | |
} | |
async createWorkspace(name?: string) { | |
await this.page.getByRole("button", { name: "New workspace" }).click(); | |
await this.page.locator('input[name="workspaceName"]').click(); | |
let workspaceName: string; | |
if (name) { | |
workspaceName = name; | |
await this.page.locator('input[name="workspaceName"]').fill(name); | |
} else { | |
workspaceName = await this.page | |
.locator('input[name="workspaceName"]') | |
.inputValue(); | |
} | |
await this.page.locator('input[name="workspaceName"]').press("Enter"); | |
const ws = new Workspace(this.page, workspaceName); | |
await ws.setEnv("LynxKite Graph Analytics"); | |
return ws; | |
} | |
async openWorkspace(name: string) { | |
await this.workspace(name).click(); | |
return new Workspace(this.page, name); | |
} | |
async createFolder(folderName?: string) { | |
await this.page.getByRole("button", { name: "New folder" }).click(); | |
await this.page.locator('input[name="folderName"]').click(); | |
if (folderName) { | |
await this.page.locator('input[name="folderName"]').fill(folderName); | |
} | |
await this.page.locator('input[name="folderName"]').press("Enter"); | |
} | |
async deleteEntry(entryName: string) { | |
await this.getEntry(entryName).locator("button").click(); | |
await this.page.reload(); | |
} | |
currentFolder() { | |
return this.page.locator(".current-folder"); | |
} | |
async goHome() { | |
await this.page.locator('a[href="/dir/"]').click(); | |
} | |
} | |