diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 251f9c4..3b2a7a7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -84,6 +84,42 @@ jobs:
name: ${{ steps.artifact.outputs.filename }}
path: ./build/distributions/content/*/*
+ # Run Playwright UI tests
+ ui-test:
+ name: UI Tests
+ needs: [ build ]
+ runs-on: ubuntu-latest
+ steps:
+
+ - name: Fetch Sources
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: ui/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+ working-directory: ui
+
+ - name: Install Playwright browsers
+ run: npx playwright install chromium --with-deps
+ working-directory: ui
+
+ - name: Run Playwright tests
+ run: npx playwright test
+ working-directory: ui
+
+ - name: Upload Playwright report
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: playwright-report
+ path: ui/playwright-report/
+
# Run tests and upload a code coverage report
test:
name: Test
diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml
deleted file mode 100644
index 1263b96..0000000
--- a/.github/workflows/run-ui-tests.yml
+++ /dev/null
@@ -1,65 +0,0 @@
-# GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps:
-# - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI.
-# - Wait for IDE to start.
-# - Run UI tests with a separate Gradle task.
-#
-# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform.
-#
-# Workflow is triggered manually.
-
-name: Run UI Tests
-on:
- workflow_dispatch
-
-jobs:
-
- testUI:
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: ubuntu-latest
- runIde: |
- export DISPLAY=:99.0
- Xvfb -ac :99 -screen 0 1920x1080x16 &
- gradle runIdeForUiTests &
- - os: windows-latest
- runIde: start gradlew.bat runIdeForUiTests
- - os: macos-latest
- runIde: ./gradlew runIdeForUiTests &
-
- steps:
-
- # Check out the current repository
- - name: Fetch Sources
- uses: actions/checkout@v6
-
- # Set up the Java environment for the next steps
- - name: Setup Java
- uses: actions/setup-java@v5
- with:
- distribution: zulu
- java-version: 17
-
- # Setup Gradle
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-read-only: true
-
- # Run IDEA prepared for UI testing
- - name: Run IDE
- run: ${{ matrix.runIde }}
-
- # Wait for IDEA to be started
- - name: Health Check
- uses: jtalk/url-health-check-action@v4
- with:
- url: http://127.0.0.1:8082
- max-attempts: 15
- retry-delay: 30s
-
- # Run tests
- - name: Tests
- run: ./gradlew test
diff --git a/build.gradle.kts b/build.gradle.kts
index e98a547..1e57b35 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -146,24 +146,3 @@ tasks {
gradleVersion = providers.gradleProperty("gradleVersion").get()
}
}
-
-intellijPlatformTesting {
- runIde {
- register("runIdeForUiTests") {
- task {
- jvmArgumentProviders += CommandLineArgumentProvider {
- listOf(
- "-Drobot-server.port=8082",
- "-Dide.mac.message.dialogs.as.sheets=false",
- "-Djb.privacy.policy.text=",
- "-Djb.consents.confirmation.enabled=false",
- )
- }
- }
-
- plugins {
- robotServerPlugin()
- }
- }
- }
-}
diff --git a/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt b/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt
index c37ea39..0c2d8c6 100644
--- a/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt
+++ b/src/main/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoader.kt
@@ -7,6 +7,27 @@ class PlayerHtmlLoader(private val bridge: PlayerBridge) {
PlayerHtmlLoader::class.java.getResource("/player/index.html")?.readText()
?: error("Player UI not found — run 'npm run build' in the ui/ directory")
}
+
+ internal fun buildConfigScript(config: PlayerConfig, openLinkJs: String): String = buildString {
+ append("")
+ }
}
fun load(config: PlayerConfig) {
@@ -14,25 +35,4 @@ class PlayerHtmlLoader(private val bridge: PlayerBridge) {
val configScript = buildConfigScript(config, openLinkJs)
bridge.loadHtml(playerHtml.replace("", "$configScript"))
}
-
- private fun buildConfigScript(config: PlayerConfig, openLinkJs: String): String = buildString {
- append("")
- }
}
diff --git a/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt b/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt
new file mode 100644
index 0000000..2a8dcf1
--- /dev/null
+++ b/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt
@@ -0,0 +1,54 @@
+package dev.twango.jetplay.browser
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PlayerBridgeEscapeTest {
+
+ @Test
+ fun plainStringUnchanged() {
+ assertEquals("hello world", PlayerBridge.escapeJs("hello world"))
+ }
+
+ @Test
+ fun backslashEscaped() {
+ assertEquals("a\\\\b", PlayerBridge.escapeJs("a\\b"))
+ }
+
+ @Test
+ fun singleQuoteEscaped() {
+ assertEquals("it\\'s", PlayerBridge.escapeJs("it's"))
+ }
+
+ @Test
+ fun doubleQuoteEscaped() {
+ assertEquals("say \\\"hi\\\"", PlayerBridge.escapeJs("say \"hi\""))
+ }
+
+ @Test
+ fun newlineEscaped() {
+ assertEquals("line1\\nline2", PlayerBridge.escapeJs("line1\nline2"))
+ }
+
+ @Test
+ fun carriageReturnRemoved() {
+ assertEquals("ab", PlayerBridge.escapeJs("a\rb"))
+ }
+
+ @Test
+ fun angleBracketsEscaped() {
+ assertEquals("\\x3cscript\\x3e", PlayerBridge.escapeJs(""))
+ }
+
+ @Test
+ fun containsState() {
+ val result = buildScript(PlayerConfig(state = "loading"))
+ assertTrue(result.contains("state: 'loading'"))
+ }
+
+ @Test
+ fun containsFileName() {
+ val result = buildScript(PlayerConfig(fileName = "my-track"))
+ assertTrue(result.contains("fileName: 'my-track'"))
+ }
+
+ @Test
+ fun containsIsVideo() {
+ val resultTrue = buildScript(PlayerConfig(isVideo = true))
+ assertTrue(resultTrue.contains("isVideo: true"))
+
+ val resultFalse = buildScript(PlayerConfig(isVideo = false))
+ assertTrue(resultFalse.contains("isVideo: false"))
+ }
+
+ @Test
+ fun includesMediaUrlWhenPresent() {
+ val result = buildScript(PlayerConfig(mediaUrl = "file:///test.webm"))
+ assertTrue(result.contains("mediaUrl: 'file:///test.webm'"))
+ }
+
+ @Test
+ fun omitsMediaUrlWhenNull() {
+ val result = buildScript(PlayerConfig(mediaUrl = null))
+ assertFalse(result.contains("mediaUrl:"))
+ }
+
+ @Test
+ fun escapesSpecialCharsInFileName() {
+ val result = buildScript(PlayerConfig(fileName = "it's \"test\""))
+ assertTrue(result.contains("fileName: 'it\\'s \\x3ca\\x3e \\\"test\\\"'"))
+ }
+
+ @Test
+ fun includesErrorMessageWhenNotEmpty() {
+ val result = buildScript(PlayerConfig(errorMessage = "Something broke"))
+ assertTrue(result.contains("errorMessage: 'Something broke'"))
+ }
+
+ @Test
+ fun omitsErrorMessageWhenEmpty() {
+ val result = buildScript(PlayerConfig(errorMessage = ""))
+ assertFalse(result.contains("errorMessage:"))
+ }
+
+ @Test
+ fun includesTranscodingReasonWhenNotEmpty() {
+ val result = buildScript(PlayerConfig(transcodingReason = "AAC needs conversion"))
+ assertTrue(result.contains("transcodingReason: 'AAC needs conversion'"))
+ }
+
+ @Test
+ fun omitsTranscodingReasonWhenEmpty() {
+ val result = buildScript(PlayerConfig(transcodingReason = ""))
+ assertFalse(result.contains("transcodingReason:"))
+ }
+
+ @Test
+ fun includesDownloadingReasonWhenNotEmpty() {
+ val result = buildScript(PlayerConfig(downloadingReason = "Remote file"))
+ assertTrue(result.contains("downloadingReason: 'Remote file'"))
+ }
+
+ @Test
+ fun omitsDownloadingReasonWhenEmpty() {
+ val result = buildScript(PlayerConfig(downloadingReason = ""))
+ assertFalse(result.contains("downloadingReason:"))
+ }
+
+ @Test
+ fun includesUiStrings() {
+ val result = buildScript(
+ PlayerConfig(
+ ui = UiStrings(
+ downloadingLabel = "Loading...",
+ transcodingLabel = "Converting...",
+ transcodingTip = "Use webm",
+ errorTitle = "Error!",
+ ),
+ ),
+ )
+ assertTrue(result.contains("downloadingLabel: 'Loading...'"))
+ assertTrue(result.contains("transcodingLabel: 'Converting...'"))
+ assertTrue(result.contains("transcodingTip: 'Use webm'"))
+ assertTrue(result.contains("errorTitle: 'Error!'"))
+ }
+
+ @Test
+ fun includesOpenLinkJs() {
+ val result = buildScript(PlayerConfig(), openLinkJs = "console.log(url)")
+ assertTrue(result.contains("window.jetplayOpenLink = function(url) { console.log(url) }"))
+ }
+}
diff --git a/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt b/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt
index 49817eb..c344877 100644
--- a/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt
+++ b/src/test/kotlin/dev/twango/jetplay/editor/MediaFileEditorProviderTest.kt
@@ -46,6 +46,46 @@ class MediaFileEditorProviderTest : BasePlatformTestCase() {
assertFalse(provider.accept(project, file))
}
+ fun testAcceptsMkv() {
+ val file = myFixture.addFileToProject("test.mkv", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testAcceptsAvi() {
+ val file = myFixture.addFileToProject("test.avi", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testAcceptsMov() {
+ val file = myFixture.addFileToProject("test.mov", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testAcceptsFlac() {
+ val file = myFixture.addFileToProject("test.flac", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testAcceptsAac() {
+ val file = myFixture.addFileToProject("test.aac", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testAcceptsOpus() {
+ val file = myFixture.addFileToProject("test.opus", "").virtualFile
+ assertTrue(provider.accept(project, file))
+ }
+
+ fun testRejectsPng() {
+ val file = myFixture.addFileToProject("test.png", "").virtualFile
+ assertFalse(provider.accept(project, file))
+ }
+
+ fun testRejectsJson() {
+ val file = myFixture.addFileToProject("test.json", "").virtualFile
+ assertFalse(provider.accept(project, file))
+ }
+
fun testEditorTypeId() {
assertEquals("media-player", provider.editorTypeId)
}
diff --git a/ui/.gitignore b/ui/.gitignore
index a547bf3..3dca698 100644
--- a/ui/.gitignore
+++ b/ui/.gitignore
@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+# Playwright
+playwright-report/
+test-results/
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 4dd8bb8..e6d5ed8 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0",
"devDependencies": {
"@lucide/svelte": "^1.3.0",
+ "@playwright/test": "^1.59.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@tsconfig/svelte": "^5.0.8",
@@ -22,21 +23,21 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -45,9 +46,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -144,6 +145,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
@@ -1718,6 +1735,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "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.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
diff --git a/ui/package.json b/ui/package.json
index 3eca8e6..be0e192 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -7,10 +7,13 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
+ "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
+ "test": "playwright test",
+ "test:ui": "playwright test --ui"
},
"devDependencies": {
"@lucide/svelte": "^1.3.0",
+ "@playwright/test": "^1.59.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@tsconfig/svelte": "^5.0.8",
diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts
new file mode 100644
index 0000000..87f48c5
--- /dev/null
+++ b/ui/playwright.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 15_000,
+ retries: process.env.CI ? 1 : 0,
+ use: {
+ baseURL: 'http://localhost:5173',
+ browserName: 'chromium',
+ },
+ webServer: {
+ command: 'npm run dev',
+ port: 5173,
+ reuseExistingServer: !process.env.CI,
+ },
+})
\ No newline at end of file
diff --git a/ui/public/assets b/ui/public/assets
new file mode 120000
index 0000000..41aef43
--- /dev/null
+++ b/ui/public/assets
@@ -0,0 +1 @@
+../../assets
\ No newline at end of file
diff --git a/ui/tests/audio-player.spec.ts b/ui/tests/audio-player.spec.ts
new file mode 100644
index 0000000..5ff0e49
--- /dev/null
+++ b/ui/tests/audio-player.spec.ts
@@ -0,0 +1,128 @@
+import { test, expect } from './fixtures'
+
+const audioConfig = {
+ state: 'ready' as const,
+ fileName: 'sintel.ogg',
+ fileExtension: 'ogg',
+ mediaUrl: '/assets/sintel.ogg',
+ isVideo: false,
+}
+
+test('play button toggles playback', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const playBtn = page.locator('button.rounded-full')
+
+ // Initially paused — Play icon visible
+ await expect(playBtn).toBeVisible()
+ await expect(page.locator('audio')).toHaveJSProperty('paused', true)
+
+ await playBtn.click()
+ await expect(page.locator('audio')).toHaveJSProperty('paused', false)
+
+ await playBtn.click()
+ await expect(page.locator('audio')).toHaveJSProperty('paused', true)
+})
+
+test('skip forward button advances time', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const audio = page.locator('audio')
+
+ // Wait for metadata so skipForward doesn't clamp to zero duration
+ await page.waitForFunction(() => {
+ const el = document.querySelector('audio')
+ return el && el.duration > 0
+ })
+
+ const timeBefore = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+
+ const skipForwardBtn = page.locator('button').filter({ has: page.locator('[class*="lucide-skip-forward"]') })
+ await skipForwardBtn.click()
+
+ const timeAfter = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+ expect(timeAfter).toBeGreaterThan(timeBefore)
+})
+
+test('skip backward button decreases time', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const audio = page.locator('audio')
+
+ // Wait for metadata so seeks are effective
+ await page.waitForFunction(() => {
+ const el = document.querySelector('audio')
+ return el && el.duration > 0
+ })
+
+ // Seek near the end, then skip back
+ await audio.evaluate((el: HTMLAudioElement) => {
+ el.currentTime = el.duration
+ })
+
+ const timeBefore = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+
+ const skipBackBtn = page.locator('button').filter({ has: page.locator('[class*="lucide-skip-back"]') })
+ await skipBackBtn.click()
+
+ const timeAfter = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+ expect(timeAfter).toBeLessThan(timeBefore)
+})
+
+test('space key toggles playback', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const audio = page.locator('audio')
+
+ // Focus the player container (has tabindex="-1")
+ await page.locator('[tabindex="-1"]').focus()
+
+ await page.keyboard.press('Space')
+ await expect(audio).toHaveJSProperty('paused', false)
+
+ await page.keyboard.press('Space')
+ await expect(audio).toHaveJSProperty('paused', true)
+})
+
+test('arrow keys skip forward and backward', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const audio = page.locator('audio')
+
+ // Wait for media to load
+ await page.waitForFunction(() => {
+ const el = document.querySelector('audio')
+ return el && el.duration > 0
+ })
+
+ await page.locator('[tabindex="-1"]').focus()
+
+ // ArrowRight skips +10s from 0 (clamped to duration for short clips)
+ await page.keyboard.press('ArrowRight')
+ const afterRight = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+ expect(afterRight).toBeGreaterThan(0)
+
+ // ArrowLeft skips -10s
+ await page.keyboard.press('ArrowLeft')
+ const afterLeft = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+ expect(afterLeft).toBeLessThan(afterRight)
+})
+
+test('seek bar click seeks to position', async ({ loadApp }) => {
+ const page = await loadApp(audioConfig)
+ const audio = page.locator('audio')
+
+ // Wait for duration to load
+ await page.waitForFunction(() => {
+ const el = document.querySelector('audio')
+ return el && el.duration > 0
+ })
+
+ const seekBar = page.locator('.group.h-5')
+ const box = await seekBar.boundingBox()
+ if (!box) throw new Error('SeekBar not visible')
+
+ // Click at ~50% of seek bar
+ await seekBar.click({ position: { x: box.width * 0.5, y: box.height / 2 } })
+
+ const currentTime = await audio.evaluate((el: HTMLAudioElement) => el.currentTime)
+ const duration = await audio.evaluate((el: HTMLAudioElement) => el.duration)
+ // Should be roughly in the middle (within 20% tolerance)
+ expect(currentTime / duration).toBeGreaterThan(0.3)
+ expect(currentTime / duration).toBeLessThan(0.7)
+})
\ No newline at end of file
diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts
new file mode 100644
index 0000000..a4f7015
--- /dev/null
+++ b/ui/tests/fixtures.ts
@@ -0,0 +1,17 @@
+import { test as base, type Page } from '@playwright/test'
+
+type JetplayConfig = NonNullable
+
+export const test = base.extend<{ loadApp: (config: JetplayConfig) => Promise }>({
+ loadApp: async ({ page }, use) => {
+ await use(async (config: JetplayConfig) => {
+ await page.addInitScript((cfg) => {
+ ;(window as any).jetplay = cfg
+ }, config)
+ await page.goto('/')
+ return page
+ })
+ },
+})
+
+export { expect } from '@playwright/test'
\ No newline at end of file
diff --git a/ui/tests/states.spec.ts b/ui/tests/states.spec.ts
new file mode 100644
index 0000000..a5c5e06
--- /dev/null
+++ b/ui/tests/states.spec.ts
@@ -0,0 +1,96 @@
+import { test, expect } from './fixtures'
+
+test('audio player renders in ready state', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'ready',
+ fileName: 'sintel.ogg',
+ fileExtension: 'ogg',
+ mediaUrl: '/assets/sintel.ogg',
+ isVideo: false,
+ })
+
+ await expect(page.locator('audio')).toBeAttached()
+ await expect(page.getByText('sintel.ogg')).toBeVisible()
+ // Play button (the round one with Play icon) should be visible
+ await expect(page.locator('button.rounded-full')).toBeVisible()
+})
+
+test('video player renders in ready state', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'ready',
+ fileName: 'sintel.webm',
+ fileExtension: 'webm',
+ mediaUrl: '/assets/sintel.webm',
+ isVideo: true,
+ })
+
+ await expect(page.locator('video')).toBeAttached()
+})
+
+test('downloading state shows progress and file name', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'downloading',
+ fileName: 'big-file.mp4',
+ })
+
+ await expect(page.getByText('Downloading\u2026')).toBeVisible()
+ await expect(page.getByText('big-file.mp4')).toBeVisible()
+ // Progress bar container exists
+ await expect(page.locator('.progress-fill')).toBeAttached()
+})
+
+test('downloading state shows reason when provided', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'downloading',
+ fileName: 'remote.mp4',
+ downloadingReason: 'Remote file needs to be downloaded',
+ })
+
+ await expect(page.getByText('Remote file needs to be downloaded')).toBeVisible()
+})
+
+test('transcoding state shows progress and tip', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'loading',
+ fileName: 'track.aac',
+ transcodingReason: 'AAC needs conversion',
+ })
+
+ await expect(page.getByText('Converting for playback\u2026')).toBeVisible()
+ await expect(page.getByText('AAC needs conversion')).toBeVisible()
+ await expect(page.getByText(/\.webm.*\.ogg.*\.opus/)).toBeVisible()
+ await expect(page.getByText('track.aac')).toBeVisible()
+})
+
+test('error state shows message', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'error',
+ errorMessage: 'Codec not supported',
+ })
+
+ await expect(page.getByText('Unable to play this file')).toBeVisible()
+ await expect(page.getByText('Codec not supported')).toBeVisible()
+})
+
+test('custom UI strings override defaults', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'error',
+ errorMessage: 'Something went wrong',
+ ui: { errorTitle: 'Custom Error Title' },
+ })
+
+ await expect(page.getByText('Custom Error Title')).toBeVisible()
+ await expect(page.getByText('Something went wrong')).toBeVisible()
+})
+
+test('extension badge displays in audio player', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'ready',
+ fileName: 'track.mp3',
+ fileExtension: 'mp3',
+ mediaUrl: '/assets/sintel.mp3',
+ isVideo: false,
+ })
+
+ await expect(page.getByText('mp3', { exact: true })).toBeVisible()
+})
\ No newline at end of file
diff --git a/ui/tests/transitions.spec.ts b/ui/tests/transitions.spec.ts
new file mode 100644
index 0000000..d1c96d4
--- /dev/null
+++ b/ui/tests/transitions.spec.ts
@@ -0,0 +1,92 @@
+import { test, expect } from './fixtures'
+
+test('jetplayReady transitions to audio player', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'downloading',
+ fileName: 'track.ogg',
+ fileExtension: 'ogg',
+ isVideo: false,
+ })
+
+ await expect(page.locator('audio')).not.toBeAttached()
+
+ await page.evaluate(() => {
+ window.jetplayReady?.('/assets/sintel.ogg')
+ })
+
+ await expect(page.locator('audio')).toBeAttached()
+})
+
+test('jetplayReady transitions to video player', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'loading',
+ fileName: 'clip.webm',
+ fileExtension: 'webm',
+ isVideo: true,
+ })
+
+ await expect(page.locator('video')).not.toBeAttached()
+
+ await page.evaluate(() => {
+ window.jetplayReady?.('/assets/sintel.webm')
+ })
+
+ await expect(page.locator('video')).toBeAttached()
+})
+
+test('jetplayStartTranscoding transitions from downloading to loading', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'downloading',
+ fileName: 'track.aac',
+ })
+
+ await expect(page.getByText('Downloading\u2026')).toBeVisible()
+
+ await page.evaluate(() => {
+ window.jetplayStartTranscoding?.()
+ })
+
+ await expect(page.getByText('Converting for playback\u2026')).toBeVisible()
+})
+
+test('jetplayError transitions to error state', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'ready',
+ fileName: 'clip.webm',
+ mediaUrl: '/assets/sintel.webm',
+ isVideo: true,
+ })
+
+ await page.evaluate(() => {
+ window.jetplayError?.('Playback failed unexpectedly')
+ })
+
+ await expect(page.getByText('Unable to play this file')).toBeVisible()
+ await expect(page.getByText('Playback failed unexpectedly')).toBeVisible()
+})
+
+test('jetplayUpdateProgress updates transcoding progress', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'loading',
+ fileName: 'track.aac',
+ })
+
+ await page.evaluate(() => {
+ window.jetplayUpdateProgress?.(50)
+ })
+
+ await expect(page.getByText('50%')).toBeVisible()
+})
+
+test('jetplayUpdateDownloadProgress updates download progress', async ({ loadApp }) => {
+ const page = await loadApp({
+ state: 'downloading',
+ fileName: 'big.mp4',
+ })
+
+ await page.evaluate(() => {
+ window.jetplayUpdateDownloadProgress?.(75)
+ })
+
+ await expect(page.getByText('75%')).toBeVisible()
+})
\ No newline at end of file
diff --git a/ui/tests/video-player.spec.ts b/ui/tests/video-player.spec.ts
new file mode 100644
index 0000000..a4c27ad
--- /dev/null
+++ b/ui/tests/video-player.spec.ts
@@ -0,0 +1,115 @@
+import { test, expect } from './fixtures'
+
+const videoConfig = {
+ state: 'ready' as const,
+ fileName: 'sintel.webm',
+ fileExtension: 'webm',
+ mediaUrl: '/assets/sintel.webm',
+ isVideo: true,
+}
+
+test('click on video toggles playback', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const video = page.locator('video')
+
+ await expect(video).toHaveJSProperty('paused', true)
+
+ await video.click()
+ await expect(video).toHaveJSProperty('paused', false)
+
+ await video.click()
+ await expect(video).toHaveJSProperty('paused', true)
+})
+
+test('play/pause button in overlay controls', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const video = page.locator('video')
+
+ // The overlay play button is inside the controls bar (not the round one like audio)
+ const playBtn = page.locator('.bg-gradient-to-t button').first()
+ await expect(playBtn).toBeVisible()
+
+ await playBtn.click()
+ await expect(video).toHaveJSProperty('paused', false)
+
+ await playBtn.click()
+ await expect(video).toHaveJSProperty('paused', true)
+})
+
+test('space key toggles playback', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const video = page.locator('video')
+
+ await page.locator('[tabindex="-1"]').focus()
+
+ await page.keyboard.press('Space')
+ await expect(video).toHaveJSProperty('paused', false)
+
+ await page.keyboard.press('Space')
+ await expect(video).toHaveJSProperty('paused', true)
+})
+
+test('arrow keys skip forward and backward', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const video = page.locator('video')
+
+ // Wait for media to load
+ await page.waitForFunction(() => {
+ const el = document.querySelector('video')
+ return el && el.duration > 0
+ })
+
+ await page.locator('[tabindex="-1"]').focus()
+
+ // ArrowRight skips +5s from 0
+ await page.keyboard.press('ArrowRight')
+ const afterRight = await video.evaluate((el: HTMLVideoElement) => el.currentTime)
+ expect(afterRight).toBeGreaterThanOrEqual(4)
+
+ // ArrowLeft skips -5s
+ await page.keyboard.press('ArrowLeft')
+ const afterLeft = await video.evaluate((el: HTMLVideoElement) => el.currentTime)
+ expect(afterLeft).toBeLessThan(afterRight)
+})
+
+test('controls auto-hide when playing', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const controls = page.locator('.bg-gradient-to-t').locator('..')
+
+ // Controls visible initially
+ await expect(controls).not.toHaveClass(/opacity-0/)
+
+ // Start playing
+ await page.locator('video').click()
+ await expect(page.locator('video')).toHaveJSProperty('paused', false)
+
+ // Wait for controls to auto-hide (3s timer in component)
+ await expect(controls).toHaveClass(/opacity-0/, { timeout: 5000 })
+})
+
+test('mouse movement shows controls when hidden', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+ const controls = page.locator('.bg-gradient-to-t').locator('..')
+
+ // Start playing and wait for controls to hide
+ await page.locator('video').click()
+ await expect(controls).toHaveClass(/opacity-0/, { timeout: 5000 })
+
+ // Move mouse to show controls
+ await page.locator('[tabindex="-1"]').hover()
+ await expect(controls).not.toHaveClass(/opacity-0/)
+})
+
+test('time display shows formatted time', async ({ loadApp }) => {
+ const page = await loadApp(videoConfig)
+
+ // Wait for duration to load
+ await page.waitForFunction(() => {
+ const el = document.querySelector('video')
+ return el && el.duration > 0
+ })
+
+ // Time display should match m:ss pattern
+ const timeText = page.locator('.tabular-nums.opacity-80')
+ await expect(timeText).toHaveText(/\d+:\d{2}\.\d{2}\s*\/\s*\d+:\d{2}\.\d{2}/)
+})
\ No newline at end of file