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