Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --ignore-scripts

- name: Build packages
run: pnpm run build:packages

- name: Build UI
run: pnpm run build:ui && pnpm run build:floating-icon

- name: Run unit tests
run: pnpm run test

e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: test

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --ignore-scripts

- name: Install Playwright browsers
run: pnpm exec playwright install chromium --with-deps

- name: Build packages
run: pnpm run build:packages

- name: Build UI
run: pnpm run build:ui && pnpm run build:floating-icon

- name: Run E2E tests
run: pnpm run test:e2e

- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ playground/dist/
vite-asset-manager-thumbnails/

.claude/settings.local.json

# Playwright
playwright-report/
test-results/
e2e/.playwright/
15 changes: 15 additions & 0 deletions e2e/helpers/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test as base, type Page } from '@playwright/test'

const DASHBOARD_URL = 'http://localhost:4173/__asset_manager__/'

export const test = base.extend<{
dashboardPage: Page
}>({
dashboardPage: async ({ page }, use) => {
await page.goto(DASHBOARD_URL)
await page.waitForSelector('[role="gridcell"]', { timeout: 15_000 })
await use(page)
},
})

export { expect } from '@playwright/test'
26 changes: 26 additions & 0 deletions e2e/helpers/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const selectors = {
floatingIcon: {
container: '#vam-container',
trigger: '#vam-trigger',
overlay: '#vam-overlay',
panel: '#vam-panel',
iframe: '#vam-iframe',
triggerActive: '#vam-trigger[data-active="true"]',
panelOpen: '#vam-panel[data-open="true"]',
panelClosed: '#vam-panel[data-open="false"]',
},

dashboard: {
grid: '[role="grid"]',
gridCell: '[role="gridcell"]',
searchInput: 'input[placeholder="Search assets..."]',
previewPanel: 'aside[role="region"]',
closePreview: 'button[aria-label="Close preview panel"]',
sidebarToggle: 'button[aria-label="Toggle sidebar"]',
groupHeader: 'button:has(span.font-mono)',
},
} as const

export const DASHBOARD_PATH = '/__asset_manager__/'
export const HOST_APP_PATH = '/'
export const API_BASE = '/__asset_manager__/api'
37 changes: 37 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test'

const PORT = 4173
const BASE_URL = `http://localhost:${PORT}`
const DASHBOARD_URL = `${BASE_URL}/__asset_manager__/`

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'html',
timeout: 30_000,

use: {
baseURL: DASHBOARD_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],

webServer: {
command: `pnpm --filter playground-svelte exec vite --port ${PORT}`,
port: PORT,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
stdout: 'pipe',
stderr: 'pipe',
},
})
101 changes: 101 additions & 0 deletions e2e/tests/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test'
import { API_BASE } from '../helpers/selectors'

const BASE = `http://localhost:4173${API_BASE}`

test.describe('API', () => {
test('GET /assets/grouped returns grouped assets', async ({ request }) => {
const response = await request.get(`${BASE}/assets/grouped`)
expect(response.ok()).toBeTruthy()

const data = await response.json()
expect(data).toHaveProperty('groups')
expect(data).toHaveProperty('total')
expect(data.total).toBeGreaterThan(0)
expect(Array.isArray(data.groups)).toBe(true)

const group = data.groups[0]
expect(group).toHaveProperty('directory')
expect(group).toHaveProperty('count')
expect(group).toHaveProperty('assets')
expect(group.count).toBeGreaterThan(0)
})

test('GET /assets/grouped?type=image filters by type', async ({ request }) => {
const response = await request.get(`${BASE}/assets/grouped?type=image`)
const data = await response.json()

for (const group of data.groups) {
for (const asset of group.assets) {
expect(asset.type).toBe('image')
}
}
})

test('GET /search returns matching assets', async ({ request }) => {
const response = await request.get(`${BASE}/search?q=svelte`)
expect(response.ok()).toBeTruthy()

const data = await response.json()
expect(data).toHaveProperty('assets')
expect(data).toHaveProperty('query', 'svelte')

const names = data.assets.map((a: { name: string }) => a.name)
expect(names).toContain('svelte.svg')
})

test('GET /stats returns asset statistics', async ({ request }) => {
const response = await request.get(`${BASE}/stats`)
expect(response.ok()).toBeTruthy()

const data = await response.json()
expect(data).toHaveProperty('total')
expect(data).toHaveProperty('byType')
expect(data).toHaveProperty('totalSize')
expect(data.total).toBeGreaterThan(0)
expect(data.byType).toHaveProperty('image')
})

test('GET /thumbnail returns image data', async ({ request }) => {
const response = await request.get(`${BASE}/thumbnail?path=src/assets/svelte.svg`)
expect(response.ok()).toBeTruthy()

const contentType = response.headers()['content-type']
expect(contentType).toContain('image/')
})

test('GET /events establishes SSE connection', async ({ page }) => {
const sseResponse = await page.evaluate(async (url) => {
return new Promise<{ connected: boolean }>((resolve) => {
const es = new EventSource(url)
const timeout = setTimeout(() => {
es.close()
resolve({ connected: false })
}, 5_000)
es.onmessage = (event) => {
clearTimeout(timeout)
es.close()
const data = JSON.parse(event.data)
resolve({ connected: data.type === 'connected' })
}
es.onerror = () => {
clearTimeout(timeout)
es.close()
resolve({ connected: false })
}
})
}, `${BASE}/events`)

expect(sseResponse.connected).toBe(true)
})

test('GET /importers returns importer data', async ({ request }) => {
const response = await request.get(`${BASE}/importers?path=src/assets/svelte.svg`)
expect(response.ok()).toBeTruthy()

const data = await response.json()
expect(data).toHaveProperty('importers')
expect(data).toHaveProperty('total')
expect(data.total).toBeGreaterThan(0)
})
})
75 changes: 75 additions & 0 deletions e2e/tests/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect } from '../helpers/fixtures'
import { selectors } from '../helpers/selectors'

test.describe('Dashboard', () => {
test('loads and displays asset groups', async ({ dashboardPage: page }) => {
const gridCells = page.locator(selectors.dashboard.gridCell)
await expect(gridCells.first()).toBeVisible()

const count = await gridCells.count()
expect(count).toBeGreaterThan(0)
})

test('displays directory group headers', async ({ dashboardPage: page }) => {
await expect(page.getByText('src/assets')).toBeVisible()
await expect(page.getByText('public')).toBeVisible()
})

test('search filters assets', async ({ dashboardPage: page }) => {
const searchInput = page.locator(selectors.dashboard.searchInput)
await searchInput.fill('svelte')

await page.waitForTimeout(500)

await expect(page.getByText('svelte.svg')).toBeVisible()
await expect(page.getByText('banner.png')).not.toBeVisible()
})

test('search with no results shows empty state', async ({ dashboardPage: page }) => {
const searchInput = page.locator(selectors.dashboard.searchInput)
await searchInput.fill('nonexistent-file-xyz-zzz')

// Wait for search to complete and grid to empty
await expect(page.locator(selectors.dashboard.gridCell)).toHaveCount(0, { timeout: 5_000 })
})

test('type filter shows only matching assets', async ({ dashboardPage: page }) => {
// Use the navigation sidebar button, not the stat badge
await page.getByRole('navigation').getByRole('button', { name: /images/i }).click()

await page.waitForTimeout(300)

const gridCells = page.locator(selectors.dashboard.gridCell)
await expect(gridCells.first()).toBeVisible()
})

test('clicking asset card opens preview panel', async ({ dashboardPage: page }) => {
const firstCard = page.locator(selectors.dashboard.gridCell).first()
await firstCard.click()

const preview = page.locator(selectors.dashboard.previewPanel)
await expect(preview).toBeVisible()

const panelHeader = preview.locator('h2')
await expect(panelHeader).toBeVisible()
const headerText = await panelHeader.textContent()
expect(headerText?.length).toBeGreaterThan(0)
})

test('preview panel closes with close button', async ({ dashboardPage: page }) => {
await page.locator(selectors.dashboard.gridCell).first().click()
const preview = page.locator(selectors.dashboard.previewPanel)
await expect(preview).toBeVisible()

await page.locator(selectors.dashboard.closePreview).click()
await expect(preview).not.toBeVisible()
})

test('preview panel closes with Escape key', async ({ dashboardPage: page }) => {
await page.locator(selectors.dashboard.gridCell).first().click()
await expect(page.locator(selectors.dashboard.previewPanel)).toBeVisible()

await page.keyboard.press('Escape')
await expect(page.locator(selectors.dashboard.previewPanel)).not.toBeVisible()
})
})
48 changes: 48 additions & 0 deletions e2e/tests/floating-icon.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
import { selectors } from '../helpers/selectors'

const HOST_URL = 'http://localhost:4173/'

test.describe('Floating Icon', () => {
test.beforeEach(async ({ page }) => {
await page.goto(HOST_URL)
await page.waitForSelector(selectors.floatingIcon.trigger, { timeout: 10_000 })
})

test('floating icon trigger is visible on host page', async ({ page }) => {
await expect(page.locator(selectors.floatingIcon.trigger)).toBeVisible()
})

test('clicking trigger opens the panel', async ({ page }) => {
await page.locator(selectors.floatingIcon.trigger).click()

await expect(page.locator(selectors.floatingIcon.panelOpen)).toBeVisible()
await expect(page.locator(selectors.floatingIcon.iframe)).toBeVisible()
})

test('Alt+Shift+A keyboard shortcut toggles panel', async ({ page }) => {
await expect(page.locator(selectors.floatingIcon.panelOpen)).not.toBeVisible()

await page.keyboard.press('Alt+Shift+KeyA')
await expect(page.locator(selectors.floatingIcon.panelOpen)).toBeVisible()

await page.keyboard.press('Alt+Shift+KeyA')
await expect(page.locator(selectors.floatingIcon.panelOpen)).not.toBeVisible()
})

test('Escape key closes the panel', async ({ page }) => {
await page.locator(selectors.floatingIcon.trigger).click()
await expect(page.locator(selectors.floatingIcon.panelOpen)).toBeVisible()

await page.keyboard.press('Escape')
await expect(page.locator(selectors.floatingIcon.panelOpen)).not.toBeVisible()
})

test('dashboard loads inside iframe', async ({ page }) => {
await page.locator(selectors.floatingIcon.trigger).click()
await expect(page.locator(selectors.floatingIcon.panelOpen)).toBeVisible()

const iframe = page.frameLocator(selectors.floatingIcon.iframe)
await expect(iframe.locator('[role="grid"]').first()).toBeVisible({ timeout: 15_000 })
})
})
Loading
Loading