JMLizano JMLizano commited on
Commit
1cbdba3
·
unverified ·
1 Parent(s): 65b908a

Add playwright tests (#65)

Browse files

* Initial setup for Playwright tests

* Install full networkx dependencies
---------

Co-authored-by: JMLizano <[email protected]>

.github/workflows/test.yaml CHANGED
@@ -9,7 +9,7 @@ jobs:
9
  test:
10
  runs-on: ubuntu-latest
11
  steps:
12
- - uses: actions/checkout@v3
13
 
14
  - name: Install uv
15
  uses: astral-sh/setup-uv@v5
@@ -35,10 +35,33 @@ jobs:
35
 
36
  - name: Run app tests
37
  run: |
38
- cd lynxkite-app
39
- pytest
40
 
41
  - name: Run graph analytics tests
42
  run: |
43
- cd lynxkite-graph-analytics
44
- pytest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  test:
10
  runs-on: ubuntu-latest
11
  steps:
12
+ - uses: actions/checkout@v4
13
 
14
  - name: Install uv
15
  uses: astral-sh/setup-uv@v5
 
35
 
36
  - name: Run app tests
37
  run: |
38
+ cd lynxkite-app
39
+ pytest
40
 
41
  - name: Run graph analytics tests
42
  run: |
43
+ cd lynxkite-graph-analytics
44
+ pytest
45
+
46
+ - uses: actions/setup-node@v4
47
+ with:
48
+ node-version: lts/*
49
+
50
+ - name: Install frontend dependencies
51
+ run: |
52
+ cd lynxkite-app/web
53
+ npm i
54
+ npx playwright install --with-deps
55
+
56
+ - name: Run Playwright tests
57
+ run: |
58
+ cd lynxkite-app/web
59
+ npm run test
60
+
61
+ - uses: actions/upload-artifact@v4
62
+ name: Upload playwright report
63
+ if: ${{ !cancelled() }}
64
+ with:
65
+ name: playwright-report
66
+ path: lynxkite-app/web/playwright-report/
67
+ retention-days: 30
.gitignore CHANGED
@@ -13,3 +13,5 @@ dist
13
  build
14
  joblib-cache
15
  *.egg-info
 
 
 
13
  build
14
  joblib-cache
15
  *.egg-info
16
+
17
+ lynxkite-app/crdt_data
lynxkite-app/web/package-lock.json CHANGED
@@ -32,6 +32,8 @@
32
  },
33
  "devDependencies": {
34
  "@eslint/js": "^9.15.0",
 
 
35
  "@types/react": "^18.3.14",
36
  "@types/react-dom": "^18.3.2",
37
  "@vitejs/plugin-react-swc": "^3.5.0",
@@ -1151,6 +1153,21 @@
1151
  "node": ">=14"
1152
  }
1153
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1154
  "node_modules/@reactivedata/react": {
1155
  "version": "0.2.2",
1156
  "resolved": "https://registry.npmjs.org/@reactivedata/react/-/react-0.2.2.tgz",
@@ -1772,10 +1789,10 @@
1772
  "license": "MIT"
1773
  },
1774
  "node_modules/@types/node": {
1775
- "version": "22.10.1",
1776
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz",
1777
- "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==",
1778
- "license": "MIT",
1779
  "dependencies": {
1780
  "undici-types": "~6.20.0"
1781
  }
@@ -5284,6 +5301,50 @@
5284
  "pathe": "^1.1.2"
5285
  }
5286
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5287
  "node_modules/postcss": {
5288
  "version": "8.4.49",
5289
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@@ -6335,6 +6396,7 @@
6335
  "version": "6.20.0",
6336
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
6337
  "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
 
6338
  "license": "MIT"
6339
  },
6340
  "node_modules/unified": {
 
32
  },
33
  "devDependencies": {
34
  "@eslint/js": "^9.15.0",
35
+ "@playwright/test": "^1.50.1",
36
+ "@types/node": "^22.13.1",
37
  "@types/react": "^18.3.14",
38
  "@types/react-dom": "^18.3.2",
39
  "@vitejs/plugin-react-swc": "^3.5.0",
 
1153
  "node": ">=14"
1154
  }
1155
  },
1156
+ "node_modules/@playwright/test": {
1157
+ "version": "1.50.1",
1158
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz",
1159
+ "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==",
1160
+ "dev": true,
1161
+ "dependencies": {
1162
+ "playwright": "1.50.1"
1163
+ },
1164
+ "bin": {
1165
+ "playwright": "cli.js"
1166
+ },
1167
+ "engines": {
1168
+ "node": ">=18"
1169
+ }
1170
+ },
1171
  "node_modules/@reactivedata/react": {
1172
  "version": "0.2.2",
1173
  "resolved": "https://registry.npmjs.org/@reactivedata/react/-/react-0.2.2.tgz",
 
1789
  "license": "MIT"
1790
  },
1791
  "node_modules/@types/node": {
1792
+ "version": "22.13.1",
1793
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
1794
+ "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
1795
+ "dev": true,
1796
  "dependencies": {
1797
  "undici-types": "~6.20.0"
1798
  }
 
5301
  "pathe": "^1.1.2"
5302
  }
5303
  },
5304
+ "node_modules/playwright": {
5305
+ "version": "1.50.1",
5306
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz",
5307
+ "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==",
5308
+ "dev": true,
5309
+ "dependencies": {
5310
+ "playwright-core": "1.50.1"
5311
+ },
5312
+ "bin": {
5313
+ "playwright": "cli.js"
5314
+ },
5315
+ "engines": {
5316
+ "node": ">=18"
5317
+ },
5318
+ "optionalDependencies": {
5319
+ "fsevents": "2.3.2"
5320
+ }
5321
+ },
5322
+ "node_modules/playwright-core": {
5323
+ "version": "1.50.1",
5324
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz",
5325
+ "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==",
5326
+ "dev": true,
5327
+ "bin": {
5328
+ "playwright-core": "cli.js"
5329
+ },
5330
+ "engines": {
5331
+ "node": ">=18"
5332
+ }
5333
+ },
5334
+ "node_modules/playwright/node_modules/fsevents": {
5335
+ "version": "2.3.2",
5336
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
5337
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
5338
+ "dev": true,
5339
+ "hasInstallScript": true,
5340
+ "optional": true,
5341
+ "os": [
5342
+ "darwin"
5343
+ ],
5344
+ "engines": {
5345
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
5346
+ }
5347
+ },
5348
  "node_modules/postcss": {
5349
  "version": "8.4.49",
5350
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
 
6396
  "version": "6.20.0",
6397
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
6398
  "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
6399
+ "dev": true,
6400
  "license": "MIT"
6401
  },
6402
  "node_modules/unified": {
lynxkite-app/web/package.json CHANGED
@@ -4,6 +4,7 @@
4
  "version": "0.0.0",
5
  "type": "module",
6
  "scripts": {
 
7
  "dev": "npx vite",
8
  "build": "npx tsc -b && npx vite build",
9
  "lint": "npx eslint .",
@@ -46,7 +47,9 @@
46
  "tailwindcss": "^3.4.16",
47
  "typescript": "~5.6.2",
48
  "typescript-eslint": "^8.15.0",
49
- "vite": "^6.0.11"
 
 
50
  },
51
  "optionalDependencies": {
52
  "@rollup/rollup-linux-x64-gnu": "^4.28.1"
 
4
  "version": "0.0.0",
5
  "type": "module",
6
  "scripts": {
7
+ "test": "playwright test",
8
  "dev": "npx vite",
9
  "build": "npx tsc -b && npx vite build",
10
  "lint": "npx eslint .",
 
47
  "tailwindcss": "^3.4.16",
48
  "typescript": "~5.6.2",
49
  "typescript-eslint": "^8.15.0",
50
+ "vite": "^6.0.11",
51
+ "@playwright/test": "^1.50.1",
52
+ "@types/node": "^22.13.1"
53
  },
54
  "optionalDependencies": {
55
  "@rollup/rollup-linux-x64-gnu": "^4.28.1"
lynxkite-app/web/playwright.config.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+
4
+ export default defineConfig({
5
+ testDir: './tests',
6
+ timeout: 60000,
7
+ fullyParallel: false,
8
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
9
+ forbidOnly: !!process.env.CI,
10
+ retries: process.env.CI ? 1 : 0,
11
+ workers: 1,
12
+ reporter: process.env.CI ? [['github'], ['html']] : 'html',
13
+ use: {
14
+ /* Base URL to use in actions like `await page.goto('/')`. */
15
+ baseURL: 'http://127.0.0.1:8000',
16
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
17
+ trace: 'on-first-retry',
18
+ testIdAttribute: 'data-nodeid' // Useful for easily selecting nodes using getByTestId
19
+ },
20
+ projects: [
21
+ {
22
+ name: 'chromium',
23
+ use: { ...devices['Desktop Chrome'] },
24
+ },
25
+ ],
26
+ webServer: {
27
+ command: 'cd .. && lynxkite',
28
+ url: 'http://127.0.0.1:8000',
29
+ reuseExistingServer: !process.env.CI,
30
+ },
31
+ });
lynxkite-app/web/tests/basic.spec.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests some basic operations like box creation, deletion, and dragging.
2
+ import { test, expect } from '@playwright/test';
3
+ import { Workspace } from './lynxkite';
4
+
5
+
6
+ let workspace: Workspace;
7
+
8
+
9
+ test.beforeEach(async ({ browser }) => {
10
+ workspace = await Workspace.empty(await browser.newPage());
11
+ await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
12
+ await workspace.setEnv('LynxKite Graph Analytics');
13
+ });
14
+
15
+
16
+ test('Box creation & deletion per env', async () => {
17
+ const envs = await workspace.getEnvs();
18
+ for(const env of envs) {
19
+ await workspace.setEnv(env);
20
+ const catalog = await workspace.getCatalog();
21
+ expect(catalog).not.toHaveLength(0);
22
+ const op = catalog[0];
23
+ await workspace.addBox(op);
24
+ await expect(workspace.getBox(`${op} 1`)).toBeVisible();
25
+ await workspace.deleteBoxes([`${op} 1`]);
26
+ await expect(workspace.getBox(`${op} 1`)).not.toBeVisible();
27
+ }
28
+ });
29
+
30
+
31
+ test('Delete multi-handle boxes', async () => {
32
+ await workspace.addBox('Compute PageRank');
33
+ await workspace.deleteBoxes(['Compute PageRank 1']);
34
+ await expect(workspace.getBox('Compute PageRank 1')).not.toBeVisible();
35
+ });
36
+
37
+
38
+ test ('Drag box', async () => {
39
+ await workspace.addBox('Import Parquet');
40
+ const originalPos = await workspace.getBox('Import Parquet 1').boundingBox();
41
+ await workspace.moveBox('Import Parquet 1', {offsetX: 100, offsetY: 100});
42
+ const newPos = await workspace.getBox('Import Parquet 1').boundingBox();
43
+ // Exact position is not guaranteed, but it should have moved
44
+ expect(newPos.x).toBeGreaterThan(originalPos.x);
45
+ expect(newPos.y).toBeGreaterThan(originalPos.y);
46
+ });
47
+
48
+
49
+
lynxkite-app/web/tests/data/upload_test.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ "name"
2
+ "Adam"
3
+ "Eve"
4
+ "Bob"
5
+ "Isolated Joe"
lynxkite-app/web/tests/errors.spec.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests error reporting.
2
+ import { test, expect } from '@playwright/test';
3
+ import { Workspace } from './lynxkite';
4
+
5
+
6
+ let workspace: Workspace;
7
+
8
+
9
+ test.beforeEach(async ({ browser }) => {
10
+ workspace = await Workspace.empty(await browser.newPage());
11
+ await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
12
+ await workspace.setEnv('LynxKite Graph Analytics');
13
+ });
14
+
15
+
16
+ test('missing parameter', async () => {
17
+ // Test the correct error message is displayed when a required parameter is missing,
18
+ // and that the error message is removed when the parameter is filled.
19
+ await workspace.addBox('Create scale-free graph');
20
+ const graphBox = workspace.getBox('Create scale-free graph 1');
21
+ await graphBox.locator('input').fill('');
22
+ expect(await graphBox.locator('.error').innerText()).toBe("invalid literal for int() with base 10: ''");
23
+ await graphBox.locator('input').fill('10');
24
+ await expect(graphBox.locator('.error')).not.toBeVisible();
25
+ });
26
+
27
+
28
+ test('unknown operation', async () => {
29
+ // Test that the correct error is displayed when the operation does not belong to
30
+ // the current environment.
31
+ await workspace.addBox('Create scale-free graph');
32
+ await workspace.setEnv('LynxScribe');
33
+ const csvBox = workspace.getBox('Create scale-free graph 1');
34
+ const errorText = await csvBox.locator('.error').innerText();
35
+ expect(errorText).toBe('Operation "Create scale-free graph" not found.');
36
+ await workspace.setEnv('LynxKite Graph Analytics');
37
+ await expect(csvBox.locator('.error')).not.toBeVisible();
38
+ });
lynxkite-app/web/tests/examples.spec.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Test the execution of the example workspaces
2
+ import { test, expect } from '@playwright/test';
3
+ import { Workspace } from './lynxkite';
4
+
5
+
6
+ test('LynxKite Graph Analytics example', async ({ page }) => {
7
+ const ws = await Workspace.open(page, "NetworkX demo");
8
+ expect(await ws.isErrorFree()).toBeTruthy();
9
+ });
10
+
11
+
12
+ test('Pytorch example', async ({ page }) => {
13
+ const ws = await Workspace.open(page, "PyTorch demo");
14
+ expect(await ws.isErrorFree()).toBeTruthy();
15
+ });
16
+
17
+
18
+ test.fail('AIMO example', async ({ page }) => {
19
+ // Fails because of missing OPENAI_API_KEY
20
+ const ws = await Workspace.open(page, "AIMO");
21
+ expect(await ws.isErrorFree()).toBeTruthy();
22
+ });
23
+
24
+ test.fail('LynxScribe example', async ({ page }) => {
25
+ // Fails because of missing OPENAI_API_KEY
26
+ const ws = await Workspace.open(page, "LynxScribe demo");
27
+ expect(await ws.isErrorFree()).toBeTruthy();
28
+ });
29
+
30
+
31
+ test.fail('Graph RAG', async ({ page }) => {
32
+ // Fails due to some issue with ChromaDB
33
+ const ws = await Workspace.open(page, "Graph RAG");
34
+ expect(await ws.isErrorFree()).toBeTruthy();
35
+ });
36
+
37
+
38
+ test.fail('RAG chatbot app', async ({ page }) => {
39
+ // Fail due to all operation being unknown
40
+ const ws = await Workspace.open(page, "RAG chatbot app");
41
+ expect(await ws.isErrorFree()).toBeTruthy();
42
+ });
43
+
44
+
45
+ test('Pillow example', async ({ page }) => {
46
+ const ws = await Workspace.open(page, "Image processing");
47
+ expect(await ws.isErrorFree()).toBeTruthy();
48
+ });
49
+
lynxkite-app/web/tests/lynxkite.ts ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared testing utilities.
2
+ import { expect, Locator, Page } from '@playwright/test';
3
+
4
+
5
+ // Mirrors the "id" filter.
6
+ export function toId(x) {
7
+ return x.toLowerCase().replace(/[ !?,./]/g, '-');
8
+ }
9
+
10
+
11
+ export const ROOT = 'automated-tests';
12
+
13
+
14
+ export class Workspace {
15
+ readonly page: Page;
16
+
17
+ constructor(page: Page) {
18
+ this.page = page;
19
+ }
20
+
21
+ // Starts with a brand new workspace.
22
+ static async empty(page: Page, workspaceName?: string): Promise<Workspace> {
23
+ const splash = await Splash.open(page);
24
+ return await splash.openNewWorkspace(workspaceName ?? 'test-example');
25
+ }
26
+
27
+ static async open(page: Page, workspaceName: string): Promise<Workspace> {
28
+ const splash = await Splash.open(page);
29
+ const ws = await splash.openWorkspace(workspaceName);
30
+ await ws.waitForNodesToLoad()
31
+ await ws.expectCurrentWorkspaceIs(workspaceName);
32
+ return ws
33
+ }
34
+
35
+ async getEnvs() {
36
+ // Return all available workspace environments
37
+ return await this.page.locator('select[name="workspace-env"] option').allInnerTexts();
38
+ }
39
+
40
+ async setEnv(env: string) {
41
+ await this.page.locator('select[name="workspace-env"]').selectOption(env);
42
+ // await this.page.getByRole('combobox', {'name': 'workspace-env'}).selectOption(env);
43
+ }
44
+
45
+ async expectCurrentWorkspaceIs(name) {
46
+ await expect(this.page.locator('.ws-name')).toHaveText(name);
47
+ }
48
+
49
+ async waitForNodesToLoad() {
50
+ // This method should be used only on non empty workspaces
51
+ await this.page.locator('.react-flow__nodes').waitFor({state: 'visible'});
52
+ let nodes: Locator[] = [];
53
+ while (nodes.length === 0) {
54
+ nodes = await this.getBoxes();
55
+ }
56
+ }
57
+
58
+ async addBox(boxName) {
59
+ //TODO: Support passing box parameters (id, position, etc.)
60
+ const allBoxes = await this.getBoxes();
61
+ if (allBoxes) {
62
+ // Avoid overlapping with existing nodes
63
+ const numNodes = allBoxes.length;
64
+ await this.page.mouse.wheel(0, numNodes * 500);
65
+ }
66
+
67
+ // Some x,y offset, otherwise the box handle may fall outside the viewport.
68
+ await this.page.locator('.react-flow__pane').click({ position: { x: 20, y: 20 }});
69
+ await this.page.getByText(boxName).click();
70
+ await this.page.keyboard.press('Escape');
71
+ // Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
72
+ await new Promise(resolve => setTimeout(resolve, 200));
73
+ }
74
+
75
+ async getCatalog() {
76
+ await this.page.locator('.react-flow__pane').click();
77
+ const catalog = await this.page.locator('.node-search .matches .search-result').allInnerTexts();
78
+ // Dismiss the catalog menu
79
+ await this.page.keyboard.press('Escape');
80
+ await new Promise(resolve => setTimeout(resolve, 200));
81
+ return catalog
82
+ }
83
+
84
+ async deleteBoxes(boxIds: string[]) {
85
+ for (const boxId of boxIds) {
86
+ await this.getBoxHandle(boxId).first().click();
87
+ await this.page.keyboard.press('Backspace');
88
+ }
89
+ }
90
+
91
+ getBox(boxId: string) {
92
+ return this.page.locator(`[data-id="${boxId}"]`);
93
+ }
94
+
95
+ getBoxes() {
96
+ return this.page.locator('.react-flow__node').all();
97
+ }
98
+
99
+ getBoxHandle(boxId: string) {
100
+ return this.page.getByTestId(boxId);
101
+ }
102
+
103
+ async moveBox(boxId: string, offset? : {offsetX: number, offsetY:number}, targetPosition?: {x: number, y: number}) {
104
+ // Move a box around, it is a best effort operation, the exact target position may not be reached
105
+ const box = await this.getBox(boxId).boundingBox();
106
+ if (!box) {
107
+ return
108
+ }
109
+ const boxCenterX = box.x + box.width / 2;
110
+ const boxCenterY = box.y + box.height / 2;
111
+ await this.page.mouse.move(boxCenterX,boxCenterY);
112
+ await this.page.mouse.down();
113
+ if (targetPosition) {
114
+ await this.page.mouse.move(targetPosition.x, targetPosition.y);
115
+ } else if (offset) {
116
+ // Without steps the movement is too fast and the box is not dragged. The more steps,
117
+ // the better the movement is captured
118
+ await this.page.mouse.move(boxCenterX + offset.offsetX, boxCenterY + offset.offsetY, {steps: 5});
119
+ }
120
+ await this.page.mouse.up();
121
+ }
122
+
123
+ async connectBoxes(sourceId: string, targetId: string) {
124
+ const sourceHandle = this.getBoxHandle(sourceId)
125
+ const targetHandle = this.getBoxHandle(targetId)
126
+ await sourceHandle.hover();
127
+ await this.page.mouse.down();
128
+ await targetHandle.hover();
129
+ await this.page.mouse.up();
130
+ }
131
+
132
+ async isErrorFree(): Promise<boolean> {
133
+ const boxes = await this.getBoxes();
134
+ for (const box of boxes) {
135
+ if (await box.locator('.error').isVisible()) {
136
+ return false;
137
+ }
138
+ }
139
+ return true;
140
+ }
141
+ }
142
+
143
+
144
+ export class Splash {
145
+ page: Page;
146
+ root: Locator;
147
+
148
+ constructor(page) {
149
+ this.page = page;
150
+ this.root = page.locator('#splash');
151
+ }
152
+
153
+ // Opens the LynxKite directory browser in the root.
154
+ static async open(page: Page): Promise<Splash> {
155
+ await page.goto('/');
156
+ await page.evaluate(() => {
157
+ window.sessionStorage.clear();
158
+ window.localStorage.clear();
159
+ });
160
+ await page.reload();
161
+ const splash = new Splash(page);
162
+ return splash;
163
+ }
164
+
165
+ workspace(name: string) {
166
+ return this.page.getByRole('link', { name: name });
167
+ }
168
+
169
+ async openNewWorkspace(name: string) {
170
+ // TODO: Support workspace naming
171
+ await this.page.getByRole('link', { name: 'New workspace' }).click();
172
+ const ws = new Workspace(this.page);
173
+ return ws;
174
+ }
175
+
176
+ async openWorkspace(name: string) {
177
+ await this.workspace(name).click();
178
+ return new Workspace(this.page);
179
+ }
180
+ }
lynxkite-app/web/tests/upload.spec.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Test uploading a file in an import box.
2
+ import { test, expect } from '@playwright/test';
3
+ import { Workspace } from './lynxkite';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+
8
+ let workspace: Workspace;
9
+
10
+
11
+ test.beforeEach(async ({ browser }) => {
12
+ workspace = await Workspace.empty(await browser.newPage());
13
+ await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
14
+ await workspace.setEnv('LynxKite Graph Analytics');
15
+ });
16
+
17
+
18
+ test('can upload and import a simple CSV', async () => {
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+ const csvPath = join(__dirname, 'data', 'upload_test.csv');
22
+
23
+ await workspace.addBox('Import CSV');
24
+ const csvBox = workspace.getBox('Import CSV 1');
25
+ const filenameInput = csvBox.locator('input.input-bordered').nth(0);
26
+ await filenameInput.click();
27
+ await filenameInput.fill(csvPath);
28
+ await filenameInput.press('Enter');
29
+
30
+ await workspace.addBox('View tables');
31
+ const tableBox = workspace.getBox('View tables 1');
32
+ await workspace.connectBoxes('Import CSV 1', 'View tables 1');
33
+
34
+ const tableRows = tableBox.locator('table tbody tr');
35
+ await expect(tableRows).toHaveCount(4);
36
+ });
lynxkite-graph-analytics/pyproject.toml CHANGED
@@ -9,7 +9,7 @@ dependencies = [
9
  "joblib>=1.4.2",
10
  "lynxkite-core",
11
  "matplotlib>=3.10.0",
12
- "networkx>=3.4.2",
13
  "osmnx>=2.0.1",
14
  "pandas>=2.2.3",
15
  "polars[gpu]>=1.14.0",
 
9
  "joblib>=1.4.2",
10
  "lynxkite-core",
11
  "matplotlib>=3.10.0",
12
+ "networkx[default]>=3.4.2",
13
  "osmnx>=2.0.1",
14
  "pandas>=2.2.3",
15
  "polars[gpu]>=1.14.0",
lynxkite-pillow-example/pyproject.toml CHANGED
@@ -8,6 +8,8 @@ dependencies = [
8
  "fsspec>=2025.2.0",
9
  "lynxkite-core",
10
  "pillow>=11.1.0",
 
 
11
  ]
12
 
13
  [tool.uv.sources]
 
8
  "fsspec>=2025.2.0",
9
  "lynxkite-core",
10
  "pillow>=11.1.0",
11
+ "requests",
12
+ "aiohttp",
13
  ]
14
 
15
  [tool.uv.sources]