Add dashboard Playwright tests with CI
@@ -143,3 +143,112 @@ jobs:
|
||||
export HOME="$RUNNER_TEMP"
|
||||
export EXO_TESTS=1
|
||||
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: Load nix develop environment
|
||||
run: nix run github:nicknovitski/nix-develop/v1
|
||||
|
||||
- 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: Run Playwright tests
|
||||
working-directory: dashboard
|
||||
run: 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
|
||||
|
||||
@@ -29,5 +29,12 @@ dashboard/build/
|
||||
dashboard/node_modules/
|
||||
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
|
||||
hosts_*.json
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"mode-watcher": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.48.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
@@ -518,6 +519,22 @@
|
||||
"@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": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -2655,6 +2672,53 @@
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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": {
|
||||
"@playwright/test": "^1.41.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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: 120000,
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
threshold: 0.2,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -407,6 +407,7 @@
|
||||
<!-- Custom dropdown -->
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<button
|
||||
data-testid="chat-model-selector"
|
||||
bind:this={dropdownButtonRef}
|
||||
type="button"
|
||||
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
|
||||
@@ -587,6 +588,7 @@
|
||||
>
|
||||
|
||||
<textarea
|
||||
data-testid="chat-input"
|
||||
bind:this={textareaRef}
|
||||
bind:value={message}
|
||||
onkeydown={handleKeydown}
|
||||
@@ -606,6 +608,7 @@
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
data-testid="send-button"
|
||||
type="submit"
|
||||
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
|
||||
|
||||
@@ -237,6 +237,9 @@
|
||||
class="group flex {message.role === 'user'
|
||||
? 'justify-end'
|
||||
: 'justify-start'}"
|
||||
data-testid={message.role === "user"
|
||||
? "user-message"
|
||||
: "assistant-message"}
|
||||
>
|
||||
<div
|
||||
class={message.role === "user"
|
||||
|
||||
@@ -977,6 +977,7 @@
|
||||
|
||||
<!-- Launch Button -->
|
||||
<button
|
||||
data-testid="launch-button"
|
||||
onclick={onLaunch}
|
||||
disabled={isLaunching || !canFit}
|
||||
class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
|
||||
|
||||
@@ -1663,12 +1663,14 @@
|
||||
<div
|
||||
class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
|
||||
>
|
||||
<TopologyGraph
|
||||
class="w-full h-full"
|
||||
highlightedNodes={highlightedNodes()}
|
||||
filteredNodes={nodeFilter}
|
||||
onNodeClick={togglePreviewNodeFilter}
|
||||
/>
|
||||
<div data-testid="topology-graph" class="w-full h-full">
|
||||
<TopologyGraph
|
||||
class="w-full h-full"
|
||||
highlightedNodes={highlightedNodes()}
|
||||
filteredNodes={nodeFilter}
|
||||
onNodeClick={togglePreviewNodeFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thunderbolt Bridge Cycle Warning -->
|
||||
{#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"
|
||||
>
|
||||
<!-- The main topology graph - full container -->
|
||||
<TopologyGraph
|
||||
class="w-full h-full"
|
||||
highlightedNodes={highlightedNodes()}
|
||||
filteredNodes={nodeFilter}
|
||||
onNodeClick={togglePreviewNodeFilter}
|
||||
/>
|
||||
<div data-testid="topology-graph" class="w-full h-full">
|
||||
<TopologyGraph
|
||||
class="w-full h-full"
|
||||
highlightedNodes={highlightedNodes()}
|
||||
filteredNodes={nodeFilter}
|
||||
onNodeClick={togglePreviewNodeFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thunderbolt Bridge Cycle Warning -->
|
||||
{#if tbBridgeCycles.length > 0}
|
||||
@@ -2363,6 +2367,7 @@
|
||||
<!-- Model Dropdown (Custom) -->
|
||||
<div class="flex-shrink-0 mb-3 relative">
|
||||
<button
|
||||
data-testid="model-dropdown"
|
||||
type="button"
|
||||
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
|
||||
@@ -2499,6 +2504,7 @@
|
||||
model.id,
|
||||
)}
|
||||
<button
|
||||
data-testid="model-option"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (modelCanFit) {
|
||||
@@ -2777,6 +2783,7 @@
|
||||
{#each allPreviews as apiPreview, i}
|
||||
<div
|
||||
role="group"
|
||||
data-testid="model-card"
|
||||
onmouseenter={() => {
|
||||
if (apiPreview.memory_delta_by_node) {
|
||||
hoveredPreviewNodes = new Set(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { waitForTopologyLoaded } from "../helpers/wait-for-ready";
|
||||
|
||||
test.describe("Chat Message Flow", () => {
|
||||
// This test suite requires a running instance
|
||||
// It will launch one if needed
|
||||
|
||||
test("should send a chat message and receive a response", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test may need to launch an instance, so give it plenty of time
|
||||
test.setTimeout(600000); // 10 minutes
|
||||
|
||||
await page.goto("/");
|
||||
await waitForTopologyLoaded(page);
|
||||
|
||||
// Check if we already have a running instance with chat available
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
let hasChatReady = await chatInput
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
|
||||
// Check if there's a ready instance (use first() in case multiple exist)
|
||||
const readyIndicator = page.locator("text=READY").first();
|
||||
let hasReady = await readyIndicator
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasReady) {
|
||||
// Need to launch an instance first
|
||||
const modelDropdown = page.locator('[data-testid="model-dropdown"]');
|
||||
await expect(modelDropdown).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for placement previews
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const launchButton = page
|
||||
.locator('[data-testid="launch-button"]:not([disabled])')
|
||||
.first();
|
||||
let hasLaunchButton = await launchButton
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasLaunchButton) {
|
||||
// Select a model - prefer Qwen3-0.6B-4bit (smallest)
|
||||
await modelDropdown.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const qwenOption = page
|
||||
.locator('[data-testid="model-option"]', {
|
||||
hasText: /qwen.*0\.6b.*4bit/i,
|
||||
})
|
||||
.first();
|
||||
const hasQwen = await qwenOption
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (hasQwen) {
|
||||
await qwenOption.click();
|
||||
} else {
|
||||
const modelOption = page
|
||||
.locator('[data-testid="model-option"]:not([disabled])')
|
||||
.first();
|
||||
await expect(modelOption).toBeVisible({ timeout: 5000 });
|
||||
await modelOption.click();
|
||||
}
|
||||
|
||||
await expect(launchButton).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
// Launch the instance
|
||||
await launchButton.click();
|
||||
|
||||
// Wait for instance to be ready
|
||||
await expect(readyIndicator).toBeVisible({ timeout: 300000 }); // 5 min for download
|
||||
}
|
||||
|
||||
// Now we should have a running instance - wait for chat input to be available
|
||||
await expect(chatInput).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Type a simple message
|
||||
await chatInput.fill("What is 2+2? Reply with just the number.");
|
||||
|
||||
// Click send button
|
||||
const sendButton = page.locator('[data-testid="send-button"]');
|
||||
await expect(sendButton).toBeVisible();
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for user message to appear
|
||||
const userMessage = page.locator('[data-testid="user-message"]');
|
||||
await expect(userMessage).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for assistant response
|
||||
const assistantMessage = page.locator('[data-testid="assistant-message"]');
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 }); // 2 min for response
|
||||
|
||||
// Verify we got some response text
|
||||
const responseText = await assistantMessage.textContent();
|
||||
expect(responseText).toBeTruthy();
|
||||
expect(responseText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { waitForTopologyLoaded } from "../helpers/wait-for-ready";
|
||||
|
||||
test.describe("Launch Model Instance", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForTopologyLoaded(page);
|
||||
});
|
||||
|
||||
test("should display topology graph on page load", async ({ page }) => {
|
||||
const topologyGraph = page.locator('[data-testid="topology-graph"]');
|
||||
await expect(topologyGraph).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display model dropdown on welcome page", async ({ page }) => {
|
||||
const modelDropdown = page.locator('[data-testid="model-dropdown"]');
|
||||
await expect(modelDropdown).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("should display model cards when model is selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const modelDropdown = page.locator('[data-testid="model-dropdown"]');
|
||||
await expect(modelDropdown).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for API to respond with placement previews
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Check if launch button is visible (indicates model card is showing)
|
||||
const launchButton = page.locator('[data-testid="launch-button"]').first();
|
||||
let hasLaunchButton = await launchButton
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasLaunchButton) {
|
||||
// No model selected yet - click dropdown and select one
|
||||
await modelDropdown.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const modelOption = page
|
||||
.locator('[data-testid="model-option"]:not([disabled])')
|
||||
.first();
|
||||
await expect(modelOption).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await modelOption.click();
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
await expect(launchButton).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test("should launch a model instance", async ({ page }) => {
|
||||
// Increase timeout for this test since it involves model download
|
||||
test.setTimeout(300000); // 5 minutes
|
||||
|
||||
const modelDropdown = page.locator('[data-testid="model-dropdown"]');
|
||||
await expect(modelDropdown).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for placement previews to load
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check if launch button is visible
|
||||
const launchButton = page
|
||||
.locator('[data-testid="launch-button"]:not([disabled])')
|
||||
.first();
|
||||
let hasLaunchButton = await launchButton
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasLaunchButton) {
|
||||
// Select a model first - prefer Qwen3-0.6B (smallest)
|
||||
await modelDropdown.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Try to find Qwen3-0.6B-4bit specifically (smallest model), otherwise take first available
|
||||
const qwenOption = page
|
||||
.locator('[data-testid="model-option"]', {
|
||||
hasText: /qwen.*0\.6b.*4bit/i,
|
||||
})
|
||||
.first();
|
||||
const hasQwen = await qwenOption
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (hasQwen) {
|
||||
await qwenOption.click();
|
||||
} else {
|
||||
const modelOption = page
|
||||
.locator('[data-testid="model-option"]:not([disabled])')
|
||||
.first();
|
||||
await expect(modelOption).toBeVisible({ timeout: 5000 });
|
||||
await modelOption.click();
|
||||
}
|
||||
|
||||
await expect(launchButton).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
// Click launch
|
||||
await launchButton.click();
|
||||
|
||||
// Wait for instance to start - could be downloading, loading, or ready
|
||||
await expect(async () => {
|
||||
const isDownloading = await page
|
||||
.locator("text=DOWNLOADING")
|
||||
.first()
|
||||
.isVisible();
|
||||
const isReady = await page.locator("text=READY").first().isVisible();
|
||||
const isLoading = await page.locator("text=LOADING").first().isVisible();
|
||||
const hasLaunching = await launchButton
|
||||
.textContent()
|
||||
.then((t) => t?.includes("LAUNCHING"));
|
||||
expect(
|
||||
isDownloading || isReady || isLoading || hasLaunching,
|
||||
).toBeTruthy();
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Wait for instance to be fully ready (this may take a while if downloading)
|
||||
await expect(page.locator("text=READY").first()).toBeVisible({
|
||||
timeout: 240000,
|
||||
}); // 4 min for download
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"emptyState": {
|
||||
"instances": {},
|
||||
"runners": {},
|
||||
"downloads": {},
|
||||
"tasks": {},
|
||||
"lastSeen": {},
|
||||
"topology": {
|
||||
"nodes": {},
|
||||
"edges": []
|
||||
},
|
||||
"lastEventAppliedIdx": -1,
|
||||
"nodeIdentities": {},
|
||||
"nodeMemory": {},
|
||||
"nodeSystem": {},
|
||||
"nodeNetwork": {},
|
||||
"nodeThunderbolt": {},
|
||||
"nodeThunderboltBridge": {},
|
||||
"thunderboltBridgeCycles": []
|
||||
},
|
||||
"singleNodeState": {
|
||||
"instances": {},
|
||||
"runners": {},
|
||||
"downloads": {},
|
||||
"tasks": {},
|
||||
"lastSeen": {
|
||||
"node-abc123": "2024-01-15T10:30:00Z"
|
||||
},
|
||||
"topology": {
|
||||
"nodes": {
|
||||
"node-abc123": {
|
||||
"peerId": "node-abc123",
|
||||
"friendlyName": "MacBook Pro",
|
||||
"systemInfo": {
|
||||
"modelId": "MacBook Pro",
|
||||
"memory": 34359738368
|
||||
},
|
||||
"macmonInfo": {
|
||||
"memory": {
|
||||
"ramTotal": 34359738368,
|
||||
"ramUsage": 17179869184
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edges": []
|
||||
},
|
||||
"lastEventAppliedIdx": 5,
|
||||
"nodeIdentities": {
|
||||
"node-abc123": {
|
||||
"peerId": "node-abc123",
|
||||
"friendlyName": "MacBook Pro"
|
||||
}
|
||||
},
|
||||
"nodeMemory": {
|
||||
"node-abc123": {
|
||||
"ramTotal": 34359738368,
|
||||
"ramUsage": 17179869184
|
||||
}
|
||||
},
|
||||
"nodeSystem": {},
|
||||
"nodeNetwork": {},
|
||||
"nodeThunderbolt": {},
|
||||
"nodeThunderboltBridge": {},
|
||||
"thunderboltBridgeCycles": []
|
||||
},
|
||||
"multiNodeState": {
|
||||
"instances": {},
|
||||
"runners": {},
|
||||
"downloads": {},
|
||||
"tasks": {},
|
||||
"lastSeen": {
|
||||
"node-abc123": "2024-01-15T10:30:00Z",
|
||||
"node-def456": "2024-01-15T10:30:00Z",
|
||||
"node-ghi789": "2024-01-15T10:30:00Z"
|
||||
},
|
||||
"topology": {
|
||||
"nodes": {
|
||||
"node-abc123": {
|
||||
"peerId": "node-abc123",
|
||||
"friendlyName": "MacBook Pro 1",
|
||||
"systemInfo": {
|
||||
"modelId": "MacBook Pro",
|
||||
"memory": 34359738368
|
||||
},
|
||||
"macmonInfo": {
|
||||
"memory": {
|
||||
"ramTotal": 34359738368,
|
||||
"ramUsage": 17179869184
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-def456": {
|
||||
"peerId": "node-def456",
|
||||
"friendlyName": "Mac Studio",
|
||||
"systemInfo": {
|
||||
"modelId": "Mac Studio",
|
||||
"memory": 68719476736
|
||||
},
|
||||
"macmonInfo": {
|
||||
"memory": {
|
||||
"ramTotal": 68719476736,
|
||||
"ramUsage": 21474836480
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-ghi789": {
|
||||
"peerId": "node-ghi789",
|
||||
"friendlyName": "Mac Mini",
|
||||
"systemInfo": {
|
||||
"modelId": "Mac Mini",
|
||||
"memory": 17179869184
|
||||
},
|
||||
"macmonInfo": {
|
||||
"memory": {
|
||||
"ramTotal": 17179869184,
|
||||
"ramUsage": 8589934592
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edges": [
|
||||
{
|
||||
"source": "node-abc123",
|
||||
"target": "node-def456"
|
||||
},
|
||||
{
|
||||
"source": "node-def456",
|
||||
"target": "node-ghi789"
|
||||
},
|
||||
{
|
||||
"source": "node-ghi789",
|
||||
"target": "node-abc123"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastEventAppliedIdx": 10,
|
||||
"nodeIdentities": {
|
||||
"node-abc123": {
|
||||
"peerId": "node-abc123",
|
||||
"friendlyName": "MacBook Pro 1"
|
||||
},
|
||||
"node-def456": {
|
||||
"peerId": "node-def456",
|
||||
"friendlyName": "Mac Studio"
|
||||
},
|
||||
"node-ghi789": {
|
||||
"peerId": "node-ghi789",
|
||||
"friendlyName": "Mac Mini"
|
||||
}
|
||||
},
|
||||
"nodeMemory": {
|
||||
"node-abc123": {
|
||||
"ramTotal": 34359738368,
|
||||
"ramUsage": 17179869184
|
||||
},
|
||||
"node-def456": {
|
||||
"ramTotal": 68719476736,
|
||||
"ramUsage": 21474836480
|
||||
},
|
||||
"node-ghi789": {
|
||||
"ramTotal": 17179869184,
|
||||
"ramUsage": 8589934592
|
||||
}
|
||||
},
|
||||
"nodeSystem": {},
|
||||
"nodeNetwork": {},
|
||||
"nodeThunderbolt": {},
|
||||
"nodeThunderboltBridge": {},
|
||||
"thunderboltBridgeCycles": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 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();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Chat Interface Visual Snapshots", () => {
|
||||
test("chat input area", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
const isVisible = await chatInput
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
// No chat interface available (no running instance)
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Take screenshot of the chat form area
|
||||
const chatForm = page.locator("form").filter({ has: chatInput });
|
||||
await expect(chatForm).toHaveScreenshot("chat-input-empty.png");
|
||||
});
|
||||
|
||||
test("chat input with text", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
const isVisible = await chatInput
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Type some text
|
||||
await chatInput.fill("This is a test message");
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const chatForm = page.locator("form").filter({ has: chatInput });
|
||||
await expect(chatForm).toHaveScreenshot("chat-input-with-text.png");
|
||||
});
|
||||
|
||||
test("empty chat messages area", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
const isVisible = await chatInput
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Look for the "AWAITING INPUT" text that appears in empty chat
|
||||
const emptyState = page.locator("text=AWAITING INPUT");
|
||||
const hasEmptyState = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
if (hasEmptyState) {
|
||||
await expect(page).toHaveScreenshot("chat-empty-state.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("chat with user message", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
const isVisible = await chatInput
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a message
|
||||
await chatInput.fill("Hello, world!");
|
||||
await page.locator('[data-testid="send-button"]').click();
|
||||
|
||||
// Wait for user message to appear
|
||||
await expect(page.locator('[data-testid="user-message"]')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Take screenshot of the chat area with the user message
|
||||
await expect(page).toHaveScreenshot("chat-with-user-message.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("send button states", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const chatInput = page.locator('[data-testid="chat-input"]');
|
||||
const isVisible = await chatInput
|
||||
.isVisible({ timeout: 10000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendButton = page.locator('[data-testid="send-button"]');
|
||||
|
||||
// Empty state - button should be disabled
|
||||
await expect(sendButton).toHaveScreenshot("send-button-disabled.png");
|
||||
|
||||
// With text - button should be enabled
|
||||
await chatInput.fill("Test message");
|
||||
await page.waitForTimeout(100);
|
||||
await expect(sendButton).toHaveScreenshot("send-button-enabled.png");
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 819 B |
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createRequire } from "module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const mockStates = require("../fixtures/mock-state.json");
|
||||
|
||||
test.describe("Homepage Visual Snapshots", () => {
|
||||
test("empty state - no nodes", async ({ page }) => {
|
||||
// Mock the state API to return empty state
|
||||
await page.route("**/state", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mockStates.emptyState),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the page to stabilize
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page).toHaveScreenshot("homepage-empty-state.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("single node connected", async ({ page }) => {
|
||||
// Mock the state API to return single node state
|
||||
await page.route("**/state", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mockStates.singleNodeState),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the topology to render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page).toHaveScreenshot("homepage-single-node.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple nodes in topology", async ({ page }) => {
|
||||
// Mock the state API to return multi-node state
|
||||
await page.route("**/state", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mockStates.multiNodeState),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the topology to render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page).toHaveScreenshot("homepage-multi-node.png", {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("topology graph element", async ({ page }) => {
|
||||
await page.route("**/state", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mockStates.singleNodeState),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const topologyGraph = page
|
||||
.locator('[data-testid="topology-graph"]')
|
||||
.first();
|
||||
await expect(topologyGraph).toBeVisible();
|
||||
|
||||
await expect(topologyGraph).toHaveScreenshot(
|
||||
"topology-graph-single-node.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 2.0 KiB |