mirror of
https://github.com/exo-explore/exo.git
synced 2026-02-25 18:58:39 -05:00
Compare commits
16 Commits
remove-pyt
...
sami/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d611f55332 | ||
|
|
66174b6509 | ||
|
|
7a2abfa0ed | ||
|
|
5aea62c8ef | ||
|
|
32ce382445 | ||
|
|
a4c42993e0 | ||
|
|
38d03ce1fa | ||
|
|
ad0b1a2ce9 | ||
|
|
6f7c9000cf | ||
|
|
c9ff05f012 | ||
|
|
164f8fb38c | ||
|
|
698eb9ad17 | ||
|
|
2ef29eeb5f | ||
|
|
e847bbd675 | ||
|
|
8f1ca88e5d | ||
|
|
075c5c545e |
136
.github/workflows/pipeline.yml
vendored
136
.github/workflows/pipeline.yml
vendored
@@ -143,3 +143,139 @@ jobs:
|
|||||||
export HOME="$RUNNER_TEMP"
|
export HOME="$RUNNER_TEMP"
|
||||||
export EXO_TESTS=1
|
export EXO_TESTS=1
|
||||||
EXO_RESOURCES_DIR="$PWD/resources" $TEST_ENV/bin/python -m pytest src -m "not slow" --import-mode=importlib
|
EXO_RESOURCES_DIR="$PWD/resources" $TEST_ENV/bin/python -m pytest src -m "not slow" --import-mode=importlib
|
||||||
|
|
||||||
|
dashboard-tests:
|
||||||
|
name: Dashboard E2E Tests
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: false
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- uses: cachix/cachix-action@v14
|
||||||
|
name: Configure Cachix
|
||||||
|
with:
|
||||||
|
name: exo
|
||||||
|
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Build Metal packages
|
||||||
|
run: |
|
||||||
|
if nix build .#metal-toolchain 2>/dev/null; then
|
||||||
|
echo "metal-toolchain built successfully (likely cache hit)"
|
||||||
|
else
|
||||||
|
echo "metal-toolchain build failed, extracting from Xcode..."
|
||||||
|
|
||||||
|
NAR_HASH="sha256-ayR5mXN4sZAddwKEG2OszGRF93k9ZFc7H0yi2xbylQw="
|
||||||
|
NAR_NAME="metal-toolchain-17C48.nar"
|
||||||
|
|
||||||
|
WORK_DIR="${RUNNER_TEMP}/metal-work"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
xcodebuild -downloadComponent MetalToolchain
|
||||||
|
|
||||||
|
DMG_PATH=$(find /System/Library/AssetsV2/com_apple_MobileAsset_MetalToolchain -name '*.dmg' 2>/dev/null | head -1)
|
||||||
|
if [ -z "$DMG_PATH" ]; then
|
||||||
|
echo "Error: Could not find Metal toolchain DMG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found DMG at: $DMG_PATH"
|
||||||
|
hdiutil attach "$DMG_PATH" -mountpoint "${WORK_DIR}/metal-dmg"
|
||||||
|
|
||||||
|
cp -R "${WORK_DIR}/metal-dmg/Metal.xctoolchain" "${WORK_DIR}/metal-export"
|
||||||
|
hdiutil detach "${WORK_DIR}/metal-dmg"
|
||||||
|
|
||||||
|
nix nar pack "${WORK_DIR}/metal-export" > "${WORK_DIR}/${NAR_NAME}"
|
||||||
|
STORE_PATH=$(nix store add --mode flat "${WORK_DIR}/${NAR_NAME}")
|
||||||
|
echo "Added NAR to store: $STORE_PATH"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
|
||||||
|
nix build .#metal-toolchain
|
||||||
|
fi
|
||||||
|
|
||||||
|
nix build .#mlx
|
||||||
|
|
||||||
|
- name: Install macmon for hardware monitoring
|
||||||
|
run: brew install macmon
|
||||||
|
|
||||||
|
- name: Load nix develop environment
|
||||||
|
run: nix run github:nicknovitski/nix-develop/v1
|
||||||
|
|
||||||
|
- name: Sync Python dependencies
|
||||||
|
run: uv sync --all-packages
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: dashboard/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dashboard dependencies
|
||||||
|
working-directory: dashboard
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: dashboard
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Build dashboard
|
||||||
|
working-directory: dashboard
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify macmon is accessible
|
||||||
|
run: |
|
||||||
|
echo "PATH: $PATH"
|
||||||
|
which macmon || echo "macmon not in PATH"
|
||||||
|
macmon --version
|
||||||
|
# Test macmon actually works - capture stderr too
|
||||||
|
echo "Testing macmon pipe output (with stderr)..."
|
||||||
|
timeout 5 macmon pipe --interval 1000 2>&1 || echo "macmon pipe exit code: $?"
|
||||||
|
# Try running macmon raw (not pipe mode)
|
||||||
|
echo "Testing macmon raw output..."
|
||||||
|
macmon raw 2>&1 | head -5 || echo "macmon raw failed"
|
||||||
|
|
||||||
|
- name: Verify Python can find macmon
|
||||||
|
run: |
|
||||||
|
echo "Testing shutil.which from uv run python..."
|
||||||
|
uv run python -c "import shutil; print('Python shutil.which macmon:', shutil.which('macmon'))"
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: dashboard
|
||||||
|
run: |
|
||||||
|
export PATH="/usr/sbin:/usr/bin:/opt/homebrew/bin:$PATH"
|
||||||
|
echo "Effective PATH: $PATH"
|
||||||
|
which macmon && echo "macmon found at $(which macmon)"
|
||||||
|
npm test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: dashboard/playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload video recordings
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: test-videos
|
||||||
|
path: dashboard/test-results/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload snapshot diffs
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: snapshot-diffs
|
||||||
|
path: dashboard/tests/**/*-snapshots/*-diff.png
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -29,5 +29,12 @@ dashboard/build/
|
|||||||
dashboard/node_modules/
|
dashboard/node_modules/
|
||||||
dashboard/.svelte-kit/
|
dashboard/.svelte-kit/
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
dashboard/test-results/
|
||||||
|
dashboard/playwright-report/
|
||||||
|
dashboard/playwright/.cache/
|
||||||
|
dashboard/tests/**/*-snapshots/*-actual.png
|
||||||
|
dashboard/tests/**/*-snapshots/*-diff.png
|
||||||
|
|
||||||
# host config snapshots
|
# host config snapshots
|
||||||
hosts_*.json
|
hosts_*.json
|
||||||
|
|||||||
72
dashboard/package-lock.json
generated
72
dashboard/package-lock.json
generated
@@ -14,12 +14,13 @@
|
|||||||
"mode-watcher": "^1.1.0"
|
"mode-watcher": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.41.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.48.4",
|
"@sveltejs/kit": "^2.48.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22.19.8",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
@@ -518,6 +519,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
|
||||||
|
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -1515,9 +1532,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.1",
|
"version": "22.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
|
||||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2655,6 +2672,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
||||||
|
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
||||||
|
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -8,18 +8,23 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:e2e": "playwright test tests/e2e",
|
||||||
|
"test:visual": "playwright test tests/visual",
|
||||||
|
"test:update-snapshots": "playwright test tests/visual --update-snapshots"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.4.2",
|
"@playwright/test": "^1.41.0",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.48.4",
|
"@sveltejs/kit": "^2.48.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22.19.8",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
43
dashboard/playwright.config.ts
Normal file
43
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:52415",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
video: "on",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "cd .. && uv run exo",
|
||||||
|
url: "http://localhost:52415/node_id",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 300000, // 5 minutes - CI needs time to install dependencies
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Ensure macmon and system tools are accessible
|
||||||
|
PATH: `/usr/sbin:/usr/bin:/opt/homebrew/bin:${process.env.PATH}`,
|
||||||
|
// Override memory detection for CI (macmon may not work on CI runners)
|
||||||
|
// 24GB is typical for GitHub Actions macos-26 runners
|
||||||
|
...(process.env.CI ? { OVERRIDE_MEMORY_MB: "24000" } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: {
|
||||||
|
maxDiffPixelRatio: 0.05,
|
||||||
|
threshold: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -407,6 +407,7 @@
|
|||||||
<!-- Custom dropdown -->
|
<!-- Custom dropdown -->
|
||||||
<div class="relative flex-1 max-w-xs">
|
<div class="relative flex-1 max-w-xs">
|
||||||
<button
|
<button
|
||||||
|
data-testid="chat-model-selector"
|
||||||
bind:this={dropdownButtonRef}
|
bind:this={dropdownButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
|
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
|
||||||
@@ -587,6 +588,7 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
data-testid="chat-input"
|
||||||
bind:this={textareaRef}
|
bind:this={textareaRef}
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
@@ -606,6 +608,7 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
data-testid="send-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSend || loading || isEditOnlyWithoutImage}
|
disabled={!canSend || loading || isEditOnlyWithoutImage}
|
||||||
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
|
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
|
||||||
|
|||||||
@@ -237,6 +237,9 @@
|
|||||||
class="group flex {message.role === 'user'
|
class="group flex {message.role === 'user'
|
||||||
? 'justify-end'
|
? 'justify-end'
|
||||||
: 'justify-start'}"
|
: 'justify-start'}"
|
||||||
|
data-testid={message.role === "user"
|
||||||
|
? "user-message"
|
||||||
|
: "assistant-message"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={message.role === "user"
|
class={message.role === "user"
|
||||||
|
|||||||
@@ -977,6 +977,7 @@
|
|||||||
|
|
||||||
<!-- Launch Button -->
|
<!-- Launch Button -->
|
||||||
<button
|
<button
|
||||||
|
data-testid="launch-button"
|
||||||
onclick={onLaunch}
|
onclick={onLaunch}
|
||||||
disabled={isLaunching || !canFit}
|
disabled={isLaunching || !canFit}
|
||||||
class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
|
class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
|
||||||
|
|||||||
@@ -1663,12 +1663,14 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
|
class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<TopologyGraph
|
<div data-testid="topology-graph" class="w-full h-full">
|
||||||
class="w-full h-full"
|
<TopologyGraph
|
||||||
highlightedNodes={highlightedNodes()}
|
class="w-full h-full"
|
||||||
filteredNodes={nodeFilter}
|
highlightedNodes={highlightedNodes()}
|
||||||
onNodeClick={togglePreviewNodeFilter}
|
filteredNodes={nodeFilter}
|
||||||
/>
|
onNodeClick={togglePreviewNodeFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Thunderbolt Bridge Cycle Warning -->
|
<!-- Thunderbolt Bridge Cycle Warning -->
|
||||||
{#if tbBridgeCycles.length > 0}
|
{#if tbBridgeCycles.length > 0}
|
||||||
@@ -1782,12 +1784,14 @@
|
|||||||
class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
|
class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- The main topology graph - full container -->
|
<!-- The main topology graph - full container -->
|
||||||
<TopologyGraph
|
<div data-testid="topology-graph" class="w-full h-full">
|
||||||
class="w-full h-full"
|
<TopologyGraph
|
||||||
highlightedNodes={highlightedNodes()}
|
class="w-full h-full"
|
||||||
filteredNodes={nodeFilter}
|
highlightedNodes={highlightedNodes()}
|
||||||
onNodeClick={togglePreviewNodeFilter}
|
filteredNodes={nodeFilter}
|
||||||
/>
|
onNodeClick={togglePreviewNodeFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Thunderbolt Bridge Cycle Warning -->
|
<!-- Thunderbolt Bridge Cycle Warning -->
|
||||||
{#if tbBridgeCycles.length > 0}
|
{#if tbBridgeCycles.length > 0}
|
||||||
@@ -2363,6 +2367,7 @@
|
|||||||
<!-- Model Dropdown (Custom) -->
|
<!-- Model Dropdown (Custom) -->
|
||||||
<div class="flex-shrink-0 mb-3 relative">
|
<div class="flex-shrink-0 mb-3 relative">
|
||||||
<button
|
<button
|
||||||
|
data-testid="model-dropdown"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
|
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
|
||||||
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
|
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
|
||||||
@@ -2499,6 +2504,7 @@
|
|||||||
model.id,
|
model.id,
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
data-testid="model-option"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (modelCanFit) {
|
if (modelCanFit) {
|
||||||
@@ -2777,6 +2783,7 @@
|
|||||||
{#each allPreviews as apiPreview, i}
|
{#each allPreviews as apiPreview, i}
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
|
data-testid="model-card"
|
||||||
onmouseenter={() => {
|
onmouseenter={() => {
|
||||||
if (apiPreview.memory_delta_by_node) {
|
if (apiPreview.memory_delta_by_node) {
|
||||||
hoveredPreviewNodes = new Set(
|
hoveredPreviewNodes = new Set(
|
||||||
|
|||||||
68
dashboard/tests/e2e/chat-message.spec.ts
Normal file
68
dashboard/tests/e2e/chat-message.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
waitForTopologyLoaded,
|
||||||
|
waitForModelCards,
|
||||||
|
waitForChatReady,
|
||||||
|
waitForAssistantMessage,
|
||||||
|
sendChatMessage,
|
||||||
|
selectModelFromLaunchDropdown,
|
||||||
|
} from "../helpers/wait-for-ready";
|
||||||
|
|
||||||
|
test.describe("Chat Message", () => {
|
||||||
|
test("should send a message and receive a response", async ({ page }) => {
|
||||||
|
// Increase timeout for this test since it involves model loading and inference
|
||||||
|
test.setTimeout(600000); // 10 minutes
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForTopologyLoaded(page);
|
||||||
|
|
||||||
|
// First select the model from the dropdown (model cards appear after selection)
|
||||||
|
await selectModelFromLaunchDropdown(page, /qwen.*0\.6b/i);
|
||||||
|
|
||||||
|
// Now wait for model cards to appear
|
||||||
|
await waitForModelCards(page);
|
||||||
|
|
||||||
|
// Find and click on the model card (should already be filtered to Qwen)
|
||||||
|
const modelCard = page.locator('[data-testid="model-card"]').first();
|
||||||
|
await expect(modelCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the launch button
|
||||||
|
const launchButton = modelCard.locator('[data-testid="launch-button"]');
|
||||||
|
await launchButton.click();
|
||||||
|
|
||||||
|
// Wait for the model to be ready (may take time to download)
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid="instance-status"]')
|
||||||
|
.filter({ hasText: /READY/i })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 300000 }); // 5 minutes for download
|
||||||
|
|
||||||
|
// Wait for chat to be ready
|
||||||
|
await waitForChatReady(page);
|
||||||
|
|
||||||
|
// Select the model in the chat selector if needed
|
||||||
|
const modelSelector = page.locator('[data-testid="chat-model-selector"]');
|
||||||
|
if (await modelSelector.isVisible()) {
|
||||||
|
await modelSelector.click();
|
||||||
|
await page.locator("text=/qwen.*0\\.6b/i").first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a simple message
|
||||||
|
await sendChatMessage(page, "What is 2+2?");
|
||||||
|
|
||||||
|
// Wait for assistant response
|
||||||
|
await waitForAssistantMessage(page, 120000); // 2 minutes for inference
|
||||||
|
|
||||||
|
// Verify the assistant message is visible
|
||||||
|
const assistantMessage = page
|
||||||
|
.locator('[data-testid="assistant-message"]')
|
||||||
|
.last();
|
||||||
|
await expect(assistantMessage).toBeVisible();
|
||||||
|
|
||||||
|
// The response should contain something (not empty)
|
||||||
|
const messageContent = await assistantMessage.textContent();
|
||||||
|
expect(messageContent).toBeTruthy();
|
||||||
|
expect(messageContent!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
dashboard/tests/e2e/launch-instance.spec.ts
Normal file
36
dashboard/tests/e2e/launch-instance.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
waitForTopologyLoaded,
|
||||||
|
waitForModelCards,
|
||||||
|
selectModelFromLaunchDropdown,
|
||||||
|
} from "../helpers/wait-for-ready";
|
||||||
|
|
||||||
|
test.describe("Launch Instance", () => {
|
||||||
|
test("should launch Qwen3-0.6B-4bit model", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForTopologyLoaded(page);
|
||||||
|
|
||||||
|
// First select the model from the dropdown (model cards appear after selection)
|
||||||
|
await selectModelFromLaunchDropdown(page, /qwen.*0\.6b/i);
|
||||||
|
|
||||||
|
// Now wait for model cards to appear
|
||||||
|
await waitForModelCards(page);
|
||||||
|
|
||||||
|
// Find and click on the model card (should already be filtered to Qwen)
|
||||||
|
const modelCard = page.locator('[data-testid="model-card"]').first();
|
||||||
|
await expect(modelCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the launch button
|
||||||
|
const launchButton = modelCard.locator('[data-testid="launch-button"]');
|
||||||
|
await launchButton.click();
|
||||||
|
|
||||||
|
// Wait for the model to start (status should change to READY or show download progress)
|
||||||
|
// The model may need to download first, so we wait with a longer timeout
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid="instance-status"]')
|
||||||
|
.filter({ hasText: /READY|downloading/i })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 300000 }); // 5 minutes for download
|
||||||
|
});
|
||||||
|
});
|
||||||
117
dashboard/tests/helpers/wait-for-ready.ts
Normal file
117
dashboard/tests/helpers/wait-for-ready.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:52415";
|
||||||
|
|
||||||
|
export async function waitForApiReady(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs = 30000,
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await page.request.get(`${BASE_URL}/node_id`);
|
||||||
|
if (response.ok()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API not ready yet, continue polling
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
throw new Error(`API did not become ready within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTopologyLoaded(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="topology-graph"]')).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForModelCards(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="model-card"]').first()).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectModelFromLaunchDropdown(
|
||||||
|
page: Page,
|
||||||
|
modelPattern: RegExp | string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Click the model dropdown in the Launch Instance panel
|
||||||
|
const dropdown = page.locator('button:has-text("SELECT MODEL")');
|
||||||
|
await expect(dropdown).toBeVisible({ timeout: 30000 });
|
||||||
|
await dropdown.click();
|
||||||
|
|
||||||
|
// Wait for dropdown menu to appear and select the model
|
||||||
|
const modelOption = page.locator("button").filter({ hasText: modelPattern });
|
||||||
|
await expect(modelOption.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await modelOption.first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChatReady(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="chat-input"]')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-testid="send-button"]')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForAssistantMessage(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs = 60000,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="assistant-message"]').last(),
|
||||||
|
).toBeVisible({ timeout: timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForStreamingComplete(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs = 120000,
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
const sendButton = page.locator('[data-testid="send-button"]');
|
||||||
|
const buttonText = await sendButton.textContent();
|
||||||
|
if (
|
||||||
|
buttonText &&
|
||||||
|
!buttonText.includes("PROCESSING") &&
|
||||||
|
!buttonText.includes("...")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
throw new Error(`Streaming did not complete within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectModel(
|
||||||
|
page: Page,
|
||||||
|
modelName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const modelSelector = page.locator('[data-testid="chat-model-selector"]');
|
||||||
|
await modelSelector.click();
|
||||||
|
await page.locator(`text=${modelName}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatMessage(
|
||||||
|
page: Page,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||||
|
await chatInput.fill(message);
|
||||||
|
const sendButton = page.locator('[data-testid="send-button"]');
|
||||||
|
await sendButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchModel(
|
||||||
|
page: Page,
|
||||||
|
modelCardIndex = 0,
|
||||||
|
): Promise<void> {
|
||||||
|
const modelCards = page.locator('[data-testid="model-card"]');
|
||||||
|
const launchButton = modelCards
|
||||||
|
.nth(modelCardIndex)
|
||||||
|
.locator('[data-testid="launch-button"]');
|
||||||
|
await launchButton.click();
|
||||||
|
}
|
||||||
26
dashboard/tests/visual/chat-interface.spec.ts
Normal file
26
dashboard/tests/visual/chat-interface.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { waitForTopologyLoaded } from "../helpers/wait-for-ready";
|
||||||
|
|
||||||
|
test.describe("Chat Interface", () => {
|
||||||
|
test("should display chat input and send button", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForTopologyLoaded(page);
|
||||||
|
|
||||||
|
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||||
|
await expect(chatInput).toBeVisible();
|
||||||
|
|
||||||
|
const sendButton = page.locator('[data-testid="send-button"]');
|
||||||
|
await expect(sendButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow typing in chat input", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForTopologyLoaded(page);
|
||||||
|
|
||||||
|
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||||
|
await expect(chatInput).toBeVisible();
|
||||||
|
|
||||||
|
await chatInput.fill("Test message");
|
||||||
|
await expect(chatInput).toHaveValue("Test message");
|
||||||
|
});
|
||||||
|
});
|
||||||
16
dashboard/tests/visual/homepage.spec.ts
Normal file
16
dashboard/tests/visual/homepage.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { waitForTopologyLoaded } from "../helpers/wait-for-ready";
|
||||||
|
|
||||||
|
test.describe("Homepage", () => {
|
||||||
|
test("should load and display key elements", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForTopologyLoaded(page);
|
||||||
|
|
||||||
|
// Verify key UI elements are present
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="topology-graph"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="chat-input"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="send-button"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user