File size: 7,286 Bytes
18d76df
d460634
18d76df
 
 
d460634
18d76df
 
d460634
18d76df
 
 
dc0212e
18d76df
dc0212e
18d76df
dc0212e
18d76df
 
 
a3bf6a6
18d76df
dc0212e
18d76df
 
 
 
 
da3ce8e
18d76df
d460634
18d76df
dc0212e
18d76df
 
d460634
da3ce8e
 
18d76df
 
 
 
 
 
 
d460634
18d76df
 
 
 
d460634
 
18d76df
 
 
7194a89
c83f1c9
f94e9a7
 
a112474
c83f1c9
18d76df
 
 
dcd48d1
 
c82d8dc
 
 
18d76df
d460634
c83f1c9
d460634
18d76df
d460634
c83f1c9
 
 
 
 
 
 
18d76df
 
c83f1c9
d460634
c83f1c9
18d76df
 
 
 
 
 
 
 
c83f1c9
18d76df
 
a2c3c92
19adf28
18d76df
 
d460634
 
 
 
 
18d76df
7ec3f60
d460634
 
18d76df
 
 
d460634
18d76df
 
 
 
 
 
a112474
 
 
18d76df
 
 
 
486cb5c
bf8f4f1
 
b546ff0
 
18d76df
 
486cb5c
18d76df
 
19adf28
b546ff0
486cb5c
 
 
 
 
 
 
 
 
 
19adf28
18d76df
a697ae8
 
 
 
 
 
c83f1c9
a697ae8
18d76df
dc0212e
d460634
0234ec8
dc0212e
18d76df
 
 
 
 
d460634
18d76df
 
d460634
18d76df
 
 
 
d460634
18d76df
 
 
 
 
 
 
 
 
 
c82d8dc
18d76df
 
dc0212e
d460634
dc0212e
 
a3bf6a6
d460634
a3bf6a6
 
 
 
d460634
18d76df
 
 
 
 
dc0212e
18d76df
dc0212e
a3bf6a6
d460634
a3bf6a6
 
 
dc0212e
 
 
d460634
dc0212e
 
 
 
d460634
dc0212e
 
 
0234ec8
dc0212e
18d76df
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
// 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.
    const allBoxes = await this.getBoxes().all();
    await this.page.locator(".ws-name").click();
    await this.page.keyboard.press("/");
    await this.page.locator(".node-search").getByText(boxName, { exact: true }).click();
    await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
  }

  async getCatalog() {
    await this.page.locator(".ws-name").click();
    await this.page.keyboard.press("/");
    const results = this.page.locator(".node-search .matches .search-result");
    await expect(results.first()).toBeVisible();
    const catalog = await results.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) {
    return this.page.locator(`.connectable[data-nodeid="${boxId}"][data-handlepos="${pos}"]`);
  }

  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 tryToConnectBoxes(sourceId: string, targetId: string) {
    const sourceHandle = this.getBoxHandle(sourceId, "right");
    const targetHandle = this.getBoxHandle(targetId, "left");
    await expect(sourceHandle).toBeVisible();
    await expect(targetHandle).toBeVisible();
    await sourceHandle.hover();
    await this.page.mouse.down();
    await expect(this.page.locator(".react-flow__connectionline")).toBeAttached({ timeout: 1000 });
    await targetHandle.hover();
    await this.page.mouse.up();
    await expect(
      this.page.locator(`.react-flow__edge[aria-label="Edge from ${sourceId} to ${targetId}"]`),
    ).toBeAttached({ timeout: 1000 });
  }
  async connectBoxes(sourceId: string, targetId: string) {
    // The method above is unreliable. I gave up after a lot of debugging and added these retries.
    while (true) {
      try {
        await this.tryToConnectBoxes(sourceId, targetId);
        return;
      } catch (e) {}
    }
  }

  async execute() {
    const request = this.page.waitForResponse(/api[/]execute_workspace/);
    await this.page.keyboard.press("r");
    await request;
  }

  async expectErrorFree(executionWaitTime?) {
    await expect(this.getBoxes().locator("text=⚠️").first()).not.toBeVisible();
  }

  async close() {
    await this.page.getByRole("link", { name: "close" }).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, exact: true });
  }

  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();
    const nameBox = this.page.locator('input[name="entryName"]');
    await nameBox.fill(name);
    await nameBox.press("Enter");
    const ws = new Workspace(this.page, name);
    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();
    const nameBox = this.page.locator('input[name="entryName"]');
    await nameBox.fill(folderName);
    await nameBox.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.getByRole("link", { name: "home" }).click();
  }
}