Add dashboard Playwright tests with CI

This commit is contained in:
Sami Khan
2026-02-04 01:02:39 +05:00
parent acb97127bf
commit 075c5c545e
24 changed files with 960 additions and 13 deletions
+109
View File
@@ -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
+7
View File
@@ -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
+64
View File
@@ -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",
+6 -1
View File
@@ -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",
+34
View File
@@ -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
+19 -12
View File
@@ -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(
+102
View File
@@ -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);
});
});
+122
View File
@@ -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
});
});
+172
View File
@@ -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": []
}
}
+102
View File
@@ -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");
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

+92
View File
@@ -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",
);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB