Spaces:
Running
Running
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 +28 -5
- .gitignore +2 -0
- lynxkite-app/web/package-lock.json +66 -4
- lynxkite-app/web/package.json +4 -1
- lynxkite-app/web/playwright.config.ts +31 -0
- lynxkite-app/web/tests/basic.spec.ts +49 -0
- lynxkite-app/web/tests/data/upload_test.csv +5 -0
- lynxkite-app/web/tests/errors.spec.ts +38 -0
- lynxkite-app/web/tests/examples.spec.ts +49 -0
- lynxkite-app/web/tests/lynxkite.ts +180 -0
- lynxkite-app/web/tests/upload.spec.ts +36 -0
- lynxkite-graph-analytics/pyproject.toml +1 -1
- lynxkite-pillow-example/pyproject.toml +2 -0
.github/workflows/test.yaml
CHANGED
@@ -9,7 +9,7 @@ jobs:
|
|
9 |
test:
|
10 |
runs-on: ubuntu-latest
|
11 |
steps:
|
12 |
-
- uses: actions/checkout@
|
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 |
-
|
39 |
-
|
40 |
|
41 |
- name: Run graph analytics tests
|
42 |
run: |
|
43 |
-
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
1776 |
-
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.
|
1777 |
-
"integrity": "sha512-
|
1778 |
-
"
|
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]
|