From 0eac5c089ff2caa7af67d495a24dc9252db659f6 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 5 Apr 2026 09:33:46 -0700 Subject: [PATCH 1/4] feat: add Playwright UI tests and expand Kotlin test coverage --- .github/workflows/build.yml | 36 ++++ .github/workflows/run-ui-tests.yml | 65 ------- build.gradle.kts | 21 --- .../jetplay/browser/PlayerHtmlLoader.kt | 42 ++--- .../jetplay/browser/PlayerBridgeEscapeTest.kt | 54 ++++++ .../jetplay/browser/PlayerHtmlLoaderTest.kt | 93 ++++++++++ .../editor/MediaFileEditorProviderTest.kt | 40 +++++ ui/.gitignore | 4 + ui/package-lock.json | 168 ++++++++++-------- ui/package.json | 5 +- ui/playwright.config.ts | 16 ++ ui/public/assets | 1 + ui/tests/audio-player.spec.ts | 119 +++++++++++++ ui/tests/fixtures.ts | 32 ++++ ui/tests/states.spec.ts | 96 ++++++++++ ui/tests/transitions.spec.ts | 92 ++++++++++ ui/tests/video-player.spec.ts | 117 ++++++++++++ 17 files changed, 820 insertions(+), 181 deletions(-) delete mode 100644 .github/workflows/run-ui-tests.yml create mode 100644 src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt create mode 100644 src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt create mode 100644 ui/playwright.config.ts create mode 120000 ui/public/assets create mode 100644 ui/tests/audio-player.spec.ts create mode 100644 ui/tests/fixtures.ts create mode 100644 ui/tests/states.spec.ts create mode 100644 ui/tests/transitions.spec.ts create mode 100644 ui/tests/video-player.spec.ts 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 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..79bb559 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", @@ -21,29 +22,6 @@ "vite-plugin-singlefile": "^2.3.2" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", @@ -144,6 +122,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", @@ -418,8 +412,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -433,8 +426,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -448,8 +440,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -463,8 +454,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -478,8 +468,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -493,8 +482,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -508,8 +496,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -523,8 +510,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -538,8 +524,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -553,8 +538,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -568,8 +552,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -583,8 +566,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -598,8 +580,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -613,8 +594,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -628,8 +608,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -643,8 +622,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -658,8 +636,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -673,8 +650,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -688,8 +664,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -703,8 +678,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -718,8 +692,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -733,8 +706,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -748,8 +720,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -763,8 +734,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -778,8 +748,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", @@ -1114,6 +1083,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1145,6 +1115,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1711,6 +1682,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1718,6 +1690,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", @@ -1870,6 +1889,7 @@ "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1981,6 +2001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2002,6 +2023,7 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", 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..0ca3b89 --- /dev/null +++ b/ui/tests/audio-player.spec.ts @@ -0,0 +1,119 @@ +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') + + // Start playback so currentTime is meaningful + await page.locator('button.rounded-full').click() + await expect(audio).toHaveJSProperty('paused', false) + + const timeBefore = await audio.evaluate((el: HTMLAudioElement) => el.currentTime) + + // Click skip forward (third button in transport controls) + 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') + + // Set time to 15s then skip back + await audio.evaluate((el: HTMLAudioElement) => { + el.currentTime = 15 + }) + + 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).toBeLessThanOrEqual(5) +}) + +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..585e576 --- /dev/null +++ b/ui/tests/fixtures.ts @@ -0,0 +1,32 @@ +import { test as base, type Page } from '@playwright/test' + +interface JetplayConfig { + mediaUrl?: string + fileName?: string + fileExtension?: string + isVideo?: boolean + state?: 'downloading' | 'loading' | 'ready' | 'error' + errorMessage?: string + transcodingReason?: string + downloadingReason?: string + ui?: { + downloadingLabel?: string + transcodingLabel?: string + transcodingTip?: string + errorTitle?: string + } +} + +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..4f8d029 --- /dev/null +++ b/ui/tests/video-player.spec.ts @@ -0,0 +1,117 @@ +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 auto-hide (3s timer + buffer) + await page.waitForTimeout(3500) + await expect(controls).toHaveClass(/opacity-0/) +}) + +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 hide + await page.locator('video').click() + await page.waitForTimeout(3500) + await expect(controls).toHaveClass(/opacity-0/) + + // 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 From 3b4e95d813699411e7b26df3d5c99682ba1b83dc Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 5 Apr 2026 10:05:43 -0700 Subject: [PATCH 2/4] chore: sync package-lock.json --- ui/package-lock.json | 110 ++++++++++++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 79bb559..e6d5ed8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -22,10 +22,33 @@ "vite-plugin-singlefile": "^2.3.2" } }, + "node_modules/@emnapi/core": { + "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.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "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, + "dependencies": { + "tslib": "^2.4.0" + } + }, "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, @@ -412,7 +435,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -426,7 +450,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -440,7 +465,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -454,7 +480,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -468,7 +495,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -482,7 +510,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -496,7 +525,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -510,7 +540,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -524,7 +555,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -538,7 +570,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -552,7 +585,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -566,7 +600,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -580,7 +615,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -594,7 +630,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -608,7 +645,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -622,7 +660,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -636,7 +675,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -650,7 +690,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -664,7 +705,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -678,7 +720,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -692,7 +735,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -706,7 +750,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -720,7 +765,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -734,7 +780,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -748,7 +795,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", @@ -1083,7 +1131,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1115,7 +1162,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1682,7 +1728,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1889,7 +1934,6 @@ "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2001,7 +2045,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2023,7 +2066,6 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", From 64f79a530980e9c0083a881211bfb1c906d74c67 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 5 Apr 2026 10:26:35 -0700 Subject: [PATCH 3/4] test: add tests for transcoding and downloading reasons in PlayerHtmlLoader --- .../jetplay/browser/PlayerHtmlLoaderTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt b/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt index 8291ae3..92a8286 100644 --- a/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt +++ b/src/test/kotlin/dev/twango/jetplay/browser/PlayerHtmlLoaderTest.kt @@ -67,6 +67,30 @@ class PlayerHtmlLoaderTest { 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( From e273268f0704cb5ce7b4327c3ee4884f22b60f40 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sun, 5 Apr 2026 11:22:51 -0700 Subject: [PATCH 4/4] test: improve audio and video player tests with metadata checks and control visibility --- ui/tests/audio-player.spec.ts | 23 ++++++++++++++++------- ui/tests/fixtures.ts | 17 +---------------- ui/tests/video-player.spec.ts | 10 ++++------ 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/ui/tests/audio-player.spec.ts b/ui/tests/audio-player.spec.ts index 0ca3b89..5ff0e49 100644 --- a/ui/tests/audio-player.spec.ts +++ b/ui/tests/audio-player.spec.ts @@ -27,13 +27,14 @@ test('skip forward button advances time', async ({ loadApp }) => { const page = await loadApp(audioConfig) const audio = page.locator('audio') - // Start playback so currentTime is meaningful - await page.locator('button.rounded-full').click() - await expect(audio).toHaveJSProperty('paused', false) + // 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) - // Click skip forward (third button in transport controls) const skipForwardBtn = page.locator('button').filter({ has: page.locator('[class*="lucide-skip-forward"]') }) await skipForwardBtn.click() @@ -45,16 +46,24 @@ test('skip backward button decreases time', async ({ loadApp }) => { const page = await loadApp(audioConfig) const audio = page.locator('audio') - // Set time to 15s then skip back + // 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 = 15 + 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).toBeLessThanOrEqual(5) + expect(timeAfter).toBeLessThan(timeBefore) }) test('space key toggles playback', async ({ loadApp }) => { diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index 585e576..a4f7015 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -1,21 +1,6 @@ import { test as base, type Page } from '@playwright/test' -interface JetplayConfig { - mediaUrl?: string - fileName?: string - fileExtension?: string - isVideo?: boolean - state?: 'downloading' | 'loading' | 'ready' | 'error' - errorMessage?: string - transcodingReason?: string - downloadingReason?: string - ui?: { - downloadingLabel?: string - transcodingLabel?: string - transcodingTip?: string - errorTitle?: string - } -} +type JetplayConfig = NonNullable export const test = base.extend<{ loadApp: (config: JetplayConfig) => Promise }>({ loadApp: async ({ page }, use) => { diff --git a/ui/tests/video-player.spec.ts b/ui/tests/video-player.spec.ts index 4f8d029..a4c27ad 100644 --- a/ui/tests/video-player.spec.ts +++ b/ui/tests/video-player.spec.ts @@ -83,19 +83,17 @@ test('controls auto-hide when playing', async ({ loadApp }) => { await page.locator('video').click() await expect(page.locator('video')).toHaveJSProperty('paused', false) - // Wait for auto-hide (3s timer + buffer) - await page.waitForTimeout(3500) - await expect(controls).toHaveClass(/opacity-0/) + // 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 hide + // Start playing and wait for controls to hide await page.locator('video').click() - await page.waitForTimeout(3500) - await expect(controls).toHaveClass(/opacity-0/) + await expect(controls).toHaveClass(/opacity-0/, { timeout: 5000 }) // Move mouse to show controls await page.locator('[tabindex="-1"]').hover()