chore(tooling): add eslint + prettier, fix the test watch-mode default
Three gaps in the frontend tooling, all in the package.json script surface.
1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.
Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
`lint`, `lint:fix`, `format`, `format:check` scripts.
The tree is error-clean (`npx eslint .` exits 0). Getting there needed
seven real one-line fixes (braced switch cases that leaked `const` across
arms, a useless regex escape, two `let`s that never change, a thrown Error
that dropped its `cause`, and two `// eslint-disable-next-line` comments
documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
that fires is set to `warn` with the reason written next to it in
eslint.config.js — notably ~94 dead bindings and `any` at the IPC
boundary. Those are real findings to drive to zero, not noise to delete.
`no-console` is OFF for now: a parallel change is moving all ~468 console
calls onto a logger facade, and turning the rule on today would collide
with it. eslint.config.js says so, and says to flip it to `error` once
that lands.
`prettier --write` is deliberately NOT run here — it would rewrite ~200
files and swamp every other diff in flight. The gate is available; the
sweep is a separate commit. Markdown and CI YAML are in .prettierignore
because both are hand-laid-out (and docs/traceability.md is generated).
2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
"Before Committing" list tells people to run it. It is now `vitest run`,
with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
scripts/test-all.sh drops the now-redundant `--run`, and
scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
them to a long-running vitest instead of the single-pass one.
3. The webdriverio e2e suite is deleted. It was last touched in January
("First working POC"), has never run since, and is not in CI — five
devDependencies and two scripts of pure decoration. Removes e2e/,
wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
devDeps, and the WebdriverIO block in .gitignore.
The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
This commit is contained in:
@@ -30,11 +30,6 @@ coverage
|
|||||||
.nyc_output
|
.nyc_output
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# WebdriverIO E2E tests
|
|
||||||
e2e/logs/
|
|
||||||
e2e/screenshots/
|
|
||||||
wdio-*.log
|
|
||||||
|
|
||||||
# Vitest
|
# Vitest
|
||||||
.vitest
|
.vitest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies & build output
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
/package/
|
||||||
|
|
||||||
|
# Rust backend (rustfmt owns this tree)
|
||||||
|
src-tauri/
|
||||||
|
|
||||||
|
# Generated by tauri-specta — regenerated on every Rust build, never hand-edited
|
||||||
|
src/lib/api/bindings.ts
|
||||||
|
|
||||||
|
# Lockfiles and generated data
|
||||||
|
bun.lock
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Generated docs (built by the publish-docs CI job)
|
||||||
|
docs/SUMMARY.md
|
||||||
|
docs/README.md
|
||||||
|
docs/api-redirect.md
|
||||||
|
docs-site/book/
|
||||||
|
|
||||||
|
# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier
|
||||||
|
# reflows tables and wrapped prose, which would swamp real doc diffs and fight
|
||||||
|
# the hand-tuned layout of docs/requirements.md and docs/traceability.md
|
||||||
|
# (the latter is generated by scripts/extract-traces.ts).
|
||||||
|
**/*.md
|
||||||
|
|
||||||
|
# CI workflow YAML — formatting churn here would obscure real pipeline diffs.
|
||||||
|
.gitea/
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": { "parser": "svelte" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# E2E Test Configuration
|
|
||||||
# Copy this file to .env and fill in your test credentials
|
|
||||||
|
|
||||||
# Jellyfin Server Configuration
|
|
||||||
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
|
||||||
TEST_SERVER_NAME=Demo Server
|
|
||||||
|
|
||||||
# Test User Credentials
|
|
||||||
TEST_USERNAME=demo
|
|
||||||
TEST_PASSWORD=
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs (for testing playback, etc.)
|
|
||||||
# You can find these IDs in your Jellyfin server
|
|
||||||
TEST_MUSIC_LIBRARY_ID=
|
|
||||||
TEST_MOVIE_LIBRARY_ID=
|
|
||||||
TEST_ARTIST_ID=
|
|
||||||
TEST_ALBUM_ID=
|
|
||||||
TEST_TRACK_ID=
|
|
||||||
TEST_MOVIE_ID=
|
|
||||||
TEST_EPISODE_ID=
|
|
||||||
|
|
||||||
# Test Timeouts (milliseconds)
|
|
||||||
TEST_TIMEOUT=60000
|
|
||||||
TEST_WAIT_TIMEOUT=15000
|
|
||||||
-376
@@ -1,376 +0,0 @@
|
|||||||
# E2E Testing with WebdriverIO
|
|
||||||
|
|
||||||
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Configure test credentials (first time only)
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
# Edit e2e/.env with your Jellyfin server details
|
|
||||||
|
|
||||||
# 2. Build the frontend
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# 3. Run E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Test Credentials
|
|
||||||
|
|
||||||
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**e2e/.env** (your private file):
|
|
||||||
```bash
|
|
||||||
# Your Jellyfin test server
|
|
||||||
TEST_SERVER_URL=https://your-jellyfin.example.com
|
|
||||||
TEST_SERVER_NAME=My Test Server
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_USERNAME=testuser
|
|
||||||
TEST_PASSWORD=yourpassword
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs
|
|
||||||
TEST_MUSIC_LIBRARY_ID=abc123
|
|
||||||
TEST_ALBUM_ID=xyz789
|
|
||||||
# ... etc
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- ✅ `.env` is gitignored - your credentials stay private
|
|
||||||
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
|
||||||
- ✅ Share `.env.example` with your team so they can set up their own
|
|
||||||
|
|
||||||
### Isolated Test Database
|
|
||||||
|
|
||||||
**Your production data is safe!** E2E tests use a completely separate database:
|
|
||||||
|
|
||||||
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
|
||||||
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
|
||||||
|
|
||||||
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
e2e/
|
|
||||||
├── .env.example # Template for test credentials
|
|
||||||
├── .env # Your credentials (gitignored)
|
|
||||||
├── specs/ # Test specifications
|
|
||||||
│ ├── app-launch.e2e.ts # App initialization tests
|
|
||||||
│ ├── auth.e2e.ts # Authentication flow
|
|
||||||
│ └── navigation.e2e.ts # Navigation and routing
|
|
||||||
├── pageobjects/ # Page Object Model (POM)
|
|
||||||
│ ├── BasePage.ts # Base class with common methods
|
|
||||||
│ ├── LoginPage.ts # Login page interactions
|
|
||||||
│ └── HomePage.ts # Home page interactions
|
|
||||||
└── helpers/ # Test utilities
|
|
||||||
├── testConfig.ts # Load .env configuration
|
|
||||||
└── testSetup.ts # Setup helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Page Object Model
|
|
||||||
|
|
||||||
Tests use the Page Object Model pattern for maintainability:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Good: Using page objects
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Bad: Direct selectors in tests
|
|
||||||
await $("#server-url").setValue("https://...");
|
|
||||||
await $("button").click();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing Tests
|
|
||||||
|
|
||||||
### Using Test Configuration
|
|
||||||
|
|
||||||
Always use `testConfig` for credentials and server details:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("My Feature", () => {
|
|
||||||
it("should test something", async () => {
|
|
||||||
// Use testConfig instead of hardcoded values
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Access optional test data
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
// Test with specific album
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Data IDs
|
|
||||||
|
|
||||||
For tests that need specific content (albums, tracks, etc.):
|
|
||||||
|
|
||||||
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
|
||||||
2. Add it to your `e2e/.env`:
|
|
||||||
```bash
|
|
||||||
TEST_ALBUM_ID=abc123def456
|
|
||||||
```
|
|
||||||
3. Use it in tests:
|
|
||||||
```typescript
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Test
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Album Playback", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Login before each test
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should play an album", async () => {
|
|
||||||
// Skip if no test album configured
|
|
||||||
if (!testConfig.albumId) {
|
|
||||||
console.log("Skipping - no TEST_ALBUM_ID configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to album
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
|
|
||||||
// Click play
|
|
||||||
const playButton = await $('[aria-label="Play"]');
|
|
||||||
await playButton.click();
|
|
||||||
|
|
||||||
// Verify playback started
|
|
||||||
const miniPlayer = await $(".mini-player");
|
|
||||||
expect(await miniPlayer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
|
|
||||||
# Run in watch mode (development)
|
|
||||||
bun run test:e2e:dev
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Before Running
|
|
||||||
|
|
||||||
**Always build the frontend first:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
cd src-tauri && cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
The debug binary expects built frontend files in the `build/` directory.
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
### app-launch.e2e.ts
|
|
||||||
Basic app initialization tests:
|
|
||||||
- App launches successfully
|
|
||||||
- UI renders correctly
|
|
||||||
- Unauthenticated users redirect to login
|
|
||||||
|
|
||||||
**Status:** ✅ Working (no credentials needed)
|
|
||||||
|
|
||||||
### auth.e2e.ts
|
|
||||||
Full authentication flow:
|
|
||||||
- Server connection (2-step process)
|
|
||||||
- Login form validation
|
|
||||||
- Error handling
|
|
||||||
- Complete auth flow
|
|
||||||
|
|
||||||
**Status:** ✅ Working with any Jellyfin server
|
|
||||||
|
|
||||||
### navigation.e2e.ts
|
|
||||||
Routing and navigation:
|
|
||||||
- Protected routes
|
|
||||||
- Redirects
|
|
||||||
- Navigation after login
|
|
||||||
|
|
||||||
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
### wdio.conf.ts
|
|
||||||
|
|
||||||
Main WebdriverIO configuration:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
port: 4444, // tauri-driver port
|
|
||||||
maxInstances: 1, // Run tests sequentially
|
|
||||||
logLevel: "warn", // Reduce noise
|
|
||||||
framework: "mocha",
|
|
||||||
timeout: 60000, // 60s test timeout
|
|
||||||
|
|
||||||
capabilities: [{
|
|
||||||
"tauri:options": {
|
|
||||||
application: "path/to/app",
|
|
||||||
env: {
|
|
||||||
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
|
||||||
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
|
||||||
| `TEST_USERNAME` | Test user username | `demo` |
|
|
||||||
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
|
||||||
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
|
||||||
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
|
||||||
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
|
||||||
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
|
||||||
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### View Application During Tests
|
|
||||||
|
|
||||||
Tests run with a visible window. To pause and inspect:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it("debug test", async () => {
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Pause for 10 seconds to inspect
|
|
||||||
await browser.pause(10000);
|
|
||||||
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Logs
|
|
||||||
|
|
||||||
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
|
||||||
- **tauri-driver logs:** Stdout/stderr from driver process
|
|
||||||
- **App logs:** Check app console (if running with dev tools)
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**"Connection refused" in browser body**
|
|
||||||
- Frontend not built: Run `bun run build`
|
|
||||||
- Solution: Always build before testing
|
|
||||||
|
|
||||||
**"Element not found" errors**
|
|
||||||
- Selector might be wrong
|
|
||||||
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
|
||||||
|
|
||||||
**"Invalid session id"**
|
|
||||||
- Normal when app closes between tests
|
|
||||||
- Each test file gets a fresh app instance
|
|
||||||
|
|
||||||
**Tests fail with "no .env file"**
|
|
||||||
- Copy `e2e/.env.example` to `e2e/.env`
|
|
||||||
- Configure your Jellyfin server details
|
|
||||||
|
|
||||||
**Database still using production data**
|
|
||||||
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
|
||||||
- Rebuild app: `cd src-tauri && cargo build`
|
|
||||||
|
|
||||||
## Platform Support
|
|
||||||
|
|
||||||
### Supported
|
|
||||||
|
|
||||||
- ✅ **Linux** - Primary development platform
|
|
||||||
- ✅ **Windows** - Supported (paths auto-detected)
|
|
||||||
- ✅ **macOS** - Supported (paths auto-detected)
|
|
||||||
|
|
||||||
### Not Supported
|
|
||||||
|
|
||||||
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
|
||||||
- Desktop tests cover 90% of app logic anyway
|
|
||||||
|
|
||||||
## Team Collaboration
|
|
||||||
|
|
||||||
### Sharing Test Configuration
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- ✅ Commit `e2e/.env.example` with template values
|
|
||||||
- ✅ Update README when adding new test data requirements
|
|
||||||
- ✅ Use descriptive variable names in `.env.example`
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- ❌ Commit `e2e/.env` with real credentials
|
|
||||||
- ❌ Hardcode server URLs in test files
|
|
||||||
- ❌ Skip authentication in tests (always test full flows)
|
|
||||||
|
|
||||||
### Setting Up for a New Team Member
|
|
||||||
|
|
||||||
1. **Clone repo**
|
|
||||||
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
|
||||||
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
|
||||||
4. **Build frontend:** `bun run build`
|
|
||||||
5. **Run tests:** `bun run test:e2e`
|
|
||||||
|
|
||||||
That's it! No shared credentials needed.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use testConfig:** Never hardcode credentials
|
|
||||||
2. **Use Page Objects:** Keep selectors out of test specs
|
|
||||||
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
|
||||||
4. **Independent Tests:** Each test should work standalone
|
|
||||||
5. **Skip Gracefully:** Check for optional test data before using
|
|
||||||
6. **Build First:** Always `bun run build` before running tests
|
|
||||||
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
|
||||||
- [ ] Create test data fixtures
|
|
||||||
- [ ] Add visual regression testing
|
|
||||||
- [ ] Mock Jellyfin API for faster, more reliable tests
|
|
||||||
- [ ] CI/CD integration (GitHub Actions)
|
|
||||||
- [ ] Test report generation
|
|
||||||
- [ ] Screenshot capture on failure
|
|
||||||
- [ ] Video recording of test runs
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [WebdriverIO Documentation](https://webdriver.io/)
|
|
||||||
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
|
||||||
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
|
||||||
- [Mocha Documentation](https://mochajs.org/)
|
|
||||||
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test configuration loaded from .env file
|
|
||||||
*/
|
|
||||||
export interface TestConfig {
|
|
||||||
serverUrl: string;
|
|
||||||
serverName: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
musicLibraryId?: string;
|
|
||||||
movieLibraryId?: string;
|
|
||||||
artistId?: string;
|
|
||||||
albumId?: string;
|
|
||||||
trackId?: string;
|
|
||||||
movieId?: string;
|
|
||||||
episodeId?: string;
|
|
||||||
timeout: number;
|
|
||||||
waitTimeout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load test configuration from .env file
|
|
||||||
* Falls back to demo server if .env doesn't exist
|
|
||||||
*/
|
|
||||||
export function loadTestConfig(): TestConfig {
|
|
||||||
const envPath = path.join(__dirname, "..", ".env");
|
|
||||||
const config: TestConfig = {
|
|
||||||
serverUrl: "https://demo.jellyfin.org/stable",
|
|
||||||
serverName: "Demo Server",
|
|
||||||
username: "demo",
|
|
||||||
password: "",
|
|
||||||
timeout: 60000,
|
|
||||||
waitTimeout: 15000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load .env file
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
|
||||||
const lines = envContent.split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (line.trim().startsWith("#") || !line.trim()) continue;
|
|
||||||
|
|
||||||
const [key, ...valueParts] = line.split("=");
|
|
||||||
const value = valueParts.join("=").trim();
|
|
||||||
|
|
||||||
switch (key.trim()) {
|
|
||||||
case "TEST_SERVER_URL":
|
|
||||||
if (value) config.serverUrl = value;
|
|
||||||
break;
|
|
||||||
case "TEST_SERVER_NAME":
|
|
||||||
if (value) config.serverName = value;
|
|
||||||
break;
|
|
||||||
case "TEST_USERNAME":
|
|
||||||
if (value) config.username = value;
|
|
||||||
break;
|
|
||||||
case "TEST_PASSWORD":
|
|
||||||
config.password = value; // Can be empty
|
|
||||||
break;
|
|
||||||
case "TEST_MUSIC_LIBRARY_ID":
|
|
||||||
if (value) config.musicLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_LIBRARY_ID":
|
|
||||||
if (value) config.movieLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ARTIST_ID":
|
|
||||||
if (value) config.artistId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ALBUM_ID":
|
|
||||||
if (value) config.albumId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TRACK_ID":
|
|
||||||
if (value) config.trackId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_ID":
|
|
||||||
if (value) config.movieId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_EPISODE_ID":
|
|
||||||
if (value) config.episodeId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TIMEOUT":
|
|
||||||
if (value) config.timeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
case "TEST_WAIT_TIMEOUT":
|
|
||||||
if (value) config.waitTimeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ No e2e/.env file found. Using demo server credentials."
|
|
||||||
);
|
|
||||||
console.warn(
|
|
||||||
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a singleton instance
|
|
||||||
export const testConfig = loadTestConfig();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the JellyTau database and cache before tests
|
|
||||||
* This ensures each test run starts with a fresh state
|
|
||||||
*/
|
|
||||||
export function clearAppData() {
|
|
||||||
const appDataDir = path.join(
|
|
||||||
os.homedir(),
|
|
||||||
".local/share/com.dtourolle.jellytau"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(appDataDir)) {
|
|
||||||
// Remove database file
|
|
||||||
const dbPath = path.join(appDataDir, "jellytau.db");
|
|
||||||
if (fs.existsSync(dbPath)) {
|
|
||||||
fs.unlinkSync(dbPath);
|
|
||||||
console.log("Cleared test database");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any cache files if needed
|
|
||||||
// Add more cleanup as needed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to clear app data:", error);
|
|
||||||
// Don't fail tests if cleanup fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for element with retries
|
|
||||||
* Useful for elements that might take time to appear
|
|
||||||
*/
|
|
||||||
export async function waitForElement(
|
|
||||||
selector: string,
|
|
||||||
timeout: number = 15000,
|
|
||||||
retries: number = 3
|
|
||||||
): Promise<WebdriverIO.Element> {
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
} catch (error) {
|
|
||||||
if (i === retries - 1) throw error;
|
|
||||||
await browser.pause(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export default class BasePage {
|
|
||||||
async waitForElement(selector: string, timeout: number = 10000) {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickElement(selector: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterText(selector: string, text: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.setValue(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getText(selector: string): Promise<string> {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
return await element.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async isElementDisplayed(selector: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
return await element.isDisplayed();
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class HomePage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get loadingSpinner() {
|
|
||||||
return $(".animate-spin");
|
|
||||||
}
|
|
||||||
|
|
||||||
get browseLibrariesButton() {
|
|
||||||
return $("button*=Browse all libraries");
|
|
||||||
}
|
|
||||||
|
|
||||||
get offlineBanner() {
|
|
||||||
return $(".bg-amber-600\\/90");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carousel sections
|
|
||||||
get heroSection() {
|
|
||||||
return $("div"); // Hero banner would need specific selector
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForHomePageLoad(timeout: number = 15000) {
|
|
||||||
// Wait for loading spinner to disappear
|
|
||||||
try {
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
|
||||||
} catch {
|
|
||||||
// Spinner might not appear if page loads quickly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOffline(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.offlineBanner.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickBrowseLibraries() {
|
|
||||||
await this.browseLibrariesButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasContent(): Promise<boolean> {
|
|
||||||
// Check if browse button exists (indicates loaded state)
|
|
||||||
try {
|
|
||||||
return await this.browseLibrariesButton.isExisting();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new HomePage();
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class LoginPage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get pageTitle() {
|
|
||||||
return $("h1");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverUrlInput() {
|
|
||||||
return $("#server-url");
|
|
||||||
}
|
|
||||||
|
|
||||||
get connectButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get usernameInput() {
|
|
||||||
return $("#username");
|
|
||||||
}
|
|
||||||
|
|
||||||
get passwordInput() {
|
|
||||||
return $("#password");
|
|
||||||
}
|
|
||||||
|
|
||||||
get signInButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get errorMessage() {
|
|
||||||
return $(".bg-red-900\\/50");
|
|
||||||
}
|
|
||||||
|
|
||||||
get backButton() {
|
|
||||||
return $("button*=Back");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverNameDisplay() {
|
|
||||||
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForLoginPage(timeout: number = 10000) {
|
|
||||||
await this.serverUrlInput.waitForDisplayed({ timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterServerUrl(url: string) {
|
|
||||||
await this.serverUrlInput.setValue(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickConnect() {
|
|
||||||
await this.connectButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectToServer(url: string) {
|
|
||||||
await this.enterServerUrl(url);
|
|
||||||
await this.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition to login form
|
|
||||||
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterUsername(username: string) {
|
|
||||||
await this.usernameInput.setValue(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterPassword(password: string) {
|
|
||||||
await this.passwordInput.setValue(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickSignIn() {
|
|
||||||
await this.signInButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
await this.enterUsername(username);
|
|
||||||
await this.enterPassword(password);
|
|
||||||
await this.clickSignIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
|
||||||
await this.waitForLoginPage();
|
|
||||||
await this.connectToServer(serverUrl);
|
|
||||||
await this.login(username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnServerStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.serverUrlInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnLoginStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.usernameInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getErrorMessage(): Promise<string> {
|
|
||||||
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
|
||||||
return await this.errorMessage.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasError(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.errorMessage.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new LoginPage();
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
|
|
||||||
describe("Application Launch", () => {
|
|
||||||
it("should launch the application", async () => {
|
|
||||||
// Wait for body element to appear
|
|
||||||
const body = await $("body");
|
|
||||||
await body.waitForDisplayed({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Verify app launched successfully
|
|
||||||
expect(await body.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the main app container", async () => {
|
|
||||||
// The app has a root div with specific classes
|
|
||||||
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
|
||||||
|
|
||||||
// Verify the main container exists
|
|
||||||
expect(await appContainer.isExisting()).toBe(true);
|
|
||||||
expect(await appContainer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show JellyTau branding", async () => {
|
|
||||||
// The app should show JellyTau title on login page (default state)
|
|
||||||
const title = await $("h1");
|
|
||||||
await title.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
const titleText = await title.getText();
|
|
||||||
expect(titleText).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// Wait for login page elements to appear
|
|
||||||
const serverUrlInput = await $("#server-url");
|
|
||||||
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify we're on the login page
|
|
||||||
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Authentication Flow", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Each test starts fresh - app should redirect to login
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Server Connection", () => {
|
|
||||||
it("should display the server connection form", async () => {
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server URL input field", async () => {
|
|
||||||
const serverInput = await LoginPage.serverUrlInput;
|
|
||||||
|
|
||||||
expect(await serverInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a disabled connect button when URL is empty", async () => {
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
|
|
||||||
// Button should be disabled when input is empty
|
|
||||||
expect(await connectButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable connect button when URL is entered", async () => {
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
expect(await connectButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid server URL", async () => {
|
|
||||||
await LoginPage.enterServerUrl("not-a-valid-url");
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for error to appear
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should transition to login form on successful connection", async () => {
|
|
||||||
// Using configured test server
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
|
|
||||||
// Should now be on login step
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Login", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Connect to configured test server before each login test
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display login form after server connection", async () => {
|
|
||||||
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server information", async () => {
|
|
||||||
// Server name and URL should be displayed
|
|
||||||
const serverName = await LoginPage.serverNameDisplay;
|
|
||||||
expect(await serverName.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have back button to return to server selection", async () => {
|
|
||||||
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
|
||||||
|
|
||||||
await LoginPage.backButton.click();
|
|
||||||
await browser.pause(500);
|
|
||||||
|
|
||||||
// Should be back on server step
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable sign in button when username is empty", async () => {
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable sign in button when username is entered", async () => {
|
|
||||||
await LoginPage.enterUsername("demo");
|
|
||||||
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid credentials", async () => {
|
|
||||||
await LoginPage.login("invalid-user", "wrong-password");
|
|
||||||
|
|
||||||
// Wait for error
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable this test by configuring e2e/.env with valid credentials
|
|
||||||
it.skip("should successfully login with valid credentials", async () => {
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Wait for redirect to home page
|
|
||||||
await browser.pause(3000);
|
|
||||||
|
|
||||||
// Should redirect away from login page
|
|
||||||
const currentUrl = await browser.getUrl();
|
|
||||||
expect(currentUrl).not.toContain("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Full Authentication Flow", () => {
|
|
||||||
it("should complete full auth flow with test server", async () => {
|
|
||||||
// Test the complete flow
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Step 1: Enter server URL
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
// Step 2: Should be on login form
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
|
|
||||||
// Step 3: Enter credentials
|
|
||||||
await LoginPage.enterUsername(testConfig.username);
|
|
||||||
await LoginPage.enterPassword(testConfig.password);
|
|
||||||
|
|
||||||
// Verify form is filled
|
|
||||||
const username = await LoginPage.usernameInput.getValue();
|
|
||||||
expect(username).toBe(testConfig.username);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import HomePage from "../pageobjects/HomePage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Navigation", () => {
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// App should automatically redirect to login when not authenticated
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent direct access to protected routes", async () => {
|
|
||||||
// Try to navigate to a protected route
|
|
||||||
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
|
||||||
await browser.pause(1000);
|
|
||||||
|
|
||||||
// Should redirect back to login
|
|
||||||
await LoginPage.waitForLoginPage(5000);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// This test requires valid authentication - configure e2e/.env to enable
|
|
||||||
it.skip("should allow navigation after login", async () => {
|
|
||||||
// Login first
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for home page
|
|
||||||
await HomePage.waitForHomePageLoad();
|
|
||||||
|
|
||||||
// Should be able to navigate
|
|
||||||
expect(await HomePage.hasContent()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// ESLint flat config for the JellyTau frontend (Svelte 5 + TypeScript strict).
|
||||||
|
//
|
||||||
|
// TRACES: | DR-205
|
||||||
|
//
|
||||||
|
// Scope: `src/` (the presentation layer), `scripts/` (build tooling), and the
|
||||||
|
// root config files. The Rust backend is linted by clippy, not by this config.
|
||||||
|
//
|
||||||
|
// Formatting is NOT ESLint's job here — `eslint-config-prettier` is applied last
|
||||||
|
// and switches off every stylistic rule that would fight `prettier`. Run
|
||||||
|
// `bun run format` / `bun run format:check` for layout.
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import ts from "typescript-eslint";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import globals from "globals";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import svelteConfig from "./svelte.config.js";
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
{
|
||||||
|
// Kept in one place so `npx eslint .` and editor integrations agree.
|
||||||
|
ignores: [
|
||||||
|
"node_modules/",
|
||||||
|
".svelte-kit/",
|
||||||
|
"build/",
|
||||||
|
"dist/",
|
||||||
|
"coverage/",
|
||||||
|
"package/",
|
||||||
|
"src-tauri/",
|
||||||
|
// Generated by tauri-specta on every Rust build — never hand-edited, and
|
||||||
|
// its shape is dictated by the Rust command definitions.
|
||||||
|
"src/lib/api/bindings.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2021,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// 🔴 TEMPORARILY OFF. A parallel migration is moving all ~468 `console.*`
|
||||||
|
// calls in `src/` onto a logger facade. Turning this on before that lands
|
||||||
|
// would paint the tree red and collide with that work.
|
||||||
|
//
|
||||||
|
// 👉 Switch this to "error" (allowing nothing, or at most
|
||||||
|
// `{ allow: ["warn", "error"] }`) once the logger-facade migration is
|
||||||
|
// merged — that is the whole point of the rule being listed here.
|
||||||
|
"no-console": "off",
|
||||||
|
|
||||||
|
// Unused values are a real signal, but `_`-prefixed args are the
|
||||||
|
// established way to say "this parameter exists for the signature".
|
||||||
|
//
|
||||||
|
// ⚠️ warn, not error: the tree carries ~94 genuinely dead bindings (stale
|
||||||
|
// imports, `$state` left over from refactors, unused `catch (e)`). Every
|
||||||
|
// one is a real finding, but fixing them here would mean ~50 unrelated
|
||||||
|
// files in this tooling commit. Clear the backlog, then promote to
|
||||||
|
// "error".
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
destructuredArrayIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Warn-only rules: each flags something real, but the existing tree has
|
||||||
|
// more instances than can be fixed without swamping unrelated diffs.
|
||||||
|
// Drive these to zero and promote them to "error" — do not delete them.
|
||||||
|
//
|
||||||
|
// `any` at the Tauri IPC boundary, mostly in code predating the
|
||||||
|
// tauri-specta bindings (~25 sites outside tests).
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
// Empty catch/if bodies that swallow an error.
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: true }],
|
||||||
|
|
||||||
|
// Prefer `import type` so type-only imports are erased cleanly by the
|
||||||
|
// bundler instead of pulling a module in at run time.
|
||||||
|
"@typescript-eslint/consistent-type-imports": "off",
|
||||||
|
|
||||||
|
// Not applicable to this app (~130 hits, all no-ops). SvelteKit's
|
||||||
|
// `resolve()` exists so hrefs keep working under a non-empty
|
||||||
|
// `kit.paths.base`; JellyTau is an adapter-static SPA served from the
|
||||||
|
// Tauri webview root and svelte.config.js sets no `base`. Re-enable this
|
||||||
|
// the day a base path is introduced — the rule is otherwise correct.
|
||||||
|
// (Declared here, not in the *.svelte block: `goto()` is also called from
|
||||||
|
// plain .ts modules such as src/lib/utils/navigation.ts.)
|
||||||
|
"svelte/no-navigation-without-resolve": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Svelte components: the parser needs the project's svelte.config.js so it
|
||||||
|
// resolves preprocessors and Svelte 5 runes the same way the build does.
|
||||||
|
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Warn-only — real findings, but each fix is a behavioural refactor that
|
||||||
|
// does not belong in a tooling commit:
|
||||||
|
// require-each-key keyed {#each} changes DOM reuse semantics
|
||||||
|
// prefer-svelte-reactivity Set/Map -> SvelteSet/SvelteMap changes
|
||||||
|
// reactivity, not just syntax
|
||||||
|
// prefer-writable-derived $state + $effect -> writable $derived
|
||||||
|
// no-at-html-tags {@html} sites need an XSS review each
|
||||||
|
"svelte/require-each-key": "warn",
|
||||||
|
"svelte/prefer-svelte-reactivity": "warn",
|
||||||
|
"svelte/prefer-writable-derived": "warn",
|
||||||
|
"svelte/no-at-html-tags": "warn",
|
||||||
|
|
||||||
|
// Warn-only: this rule cannot see the Svelte *compiler's* warning set, so
|
||||||
|
// it reports `<!-- svelte-ignore a11y_… -->` as unused when the compiler
|
||||||
|
// may still be emitting the warning it suppresses. Verify against a real
|
||||||
|
// `bun run check` before deleting any of them.
|
||||||
|
"svelte/no-unused-svelte-ignore": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Node-side tooling: build/test scripts and root config files run under
|
||||||
|
// Bun/Node, not in the webview.
|
||||||
|
files: [
|
||||||
|
"scripts/**/*.{ts,js}",
|
||||||
|
"*.config.{ts,js}",
|
||||||
|
"*.config.*.{ts,js}",
|
||||||
|
"svelte.config.js",
|
||||||
|
"eslint.config.js",
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Test files: vitest globals are enabled in vitest.config.ts.
|
||||||
|
files: ["**/*.{test,spec}.{ts,js}", "src/test/**/*.{ts,js}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.vitest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Test doubles legitimately use `any` for partial mocks.
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// `vi.mock` factories are hoisted above the import graph, so a lazy
|
||||||
|
// `require()` inside one is the documented escape hatch.
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
// Several tests deliberately replay a production assignment sequence
|
||||||
|
// (`currentStreamUrl = newStreamUrl; hasSeeked = false;`) to document the
|
||||||
|
// `$effect` they stand in for. The "useless" write is the subject under
|
||||||
|
// test, not dead code.
|
||||||
|
"no-useless-assignment": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+18
-10
@@ -10,14 +10,19 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test": "vitest",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "wdio run ./wdio.conf.ts",
|
|
||||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
|
||||||
"test:all": "./scripts/test-all.sh",
|
"test:all": "./scripts/test-all.sh",
|
||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
|
"check:links": "bash scripts/check-doc-links.sh",
|
||||||
|
"hooks:install": "./scripts/install-hooks.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
"android:build:device": "./scripts/build-android.sh --device",
|
"android:build:device": "./scripts/build-android.sh --device",
|
||||||
@@ -51,6 +56,7 @@
|
|||||||
"svelte-dnd-action": "^0.9.69"
|
"svelte-dnd-action": "^0.9.69"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
@@ -59,18 +65,20 @@
|
|||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"@wdio/cli": "^9.5.0",
|
"eslint": "^10.8.1",
|
||||||
"@wdio/local-runner": "^9.5.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"@wdio/mocha-framework": "^9.5.0",
|
"eslint-plugin-svelte": "^3.23.0",
|
||||||
"@wdio/spec-reporter": "^9.5.0",
|
"globals": "^17.11.0",
|
||||||
"happy-dom": "^20.0.11",
|
"happy-dom": "^20.0.11",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"svelte": "^5.47.1",
|
"svelte": "^5.47.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.67.0",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": ">=1.0.0 <5.0.0",
|
"vitest": ">=1.0.0 <5.0.0"
|
||||||
"webdriverio": "^9.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-1
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
|
|||||||
### `test-frontend.sh`
|
### `test-frontend.sh`
|
||||||
Run frontend tests only.
|
Run frontend tests only.
|
||||||
```bash
|
```bash
|
||||||
./scripts/test-frontend.sh # Run all tests
|
./scripts/test-frontend.sh # Single pass (same as `bun run test`)
|
||||||
./scripts/test-frontend.sh --watch # Watch mode
|
./scripts/test-frontend.sh --watch # Watch mode
|
||||||
./scripts/test-frontend.sh --ui # Open UI
|
./scripts/test-frontend.sh --ui # Open UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`bun run test` is `vitest run` — one pass, exit code, done. It used to be bare
|
||||||
|
`vitest`, which parked in watch mode; CLAUDE.md's "Before Committing" list tells
|
||||||
|
people to run it, so it had to terminate. The interactive modes moved to their
|
||||||
|
own entry points:
|
||||||
|
|
||||||
|
| Command | Runs |
|
||||||
|
|---------|------|
|
||||||
|
| `bun run test` | `vitest run` — single pass |
|
||||||
|
| `bun run test:watch` | `vitest` — watch mode |
|
||||||
|
| `bun run test:ui` | `vitest --ui` |
|
||||||
|
| `bun run test:coverage` | `vitest run --coverage` |
|
||||||
|
|
||||||
|
`test-frontend.sh` forwards any extra arguments to vitest and switches to the
|
||||||
|
long-running form automatically when it sees `--watch`, `-w`, or `--ui`.
|
||||||
|
|
||||||
### `test-rust.sh`
|
### `test-rust.sh`
|
||||||
Run Rust tests only.
|
Run Rust tests only.
|
||||||
```bash
|
```bash
|
||||||
@@ -120,6 +135,59 @@ For details, see:
|
|||||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||||
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
||||||
|
|
||||||
|
## Linting & Formatting
|
||||||
|
|
||||||
|
There is no script wrapper for these — they are plain package.json entries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint # eslint .
|
||||||
|
bun run lint:fix # eslint . --fix
|
||||||
|
bun run format # prettier --write .
|
||||||
|
bun run format:check # prettier --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Config lives in `eslint.config.js` (flat config: typescript-eslint +
|
||||||
|
eslint-plugin-svelte, tuned for Svelte 5 and TS `strict`), `.prettierrc`, and
|
||||||
|
`.prettierignore`. `src/lib/api/bindings.ts` is excluded from both — it is
|
||||||
|
generated by tauri-specta on every Rust build.
|
||||||
|
|
||||||
|
`bun run lint` is currently **error-clean but not warning-clean**: several rules
|
||||||
|
are deliberately set to `warn` because the existing tree has more hits than a
|
||||||
|
tooling change should touch (unused bindings, `any` at the IPC boundary, unkeyed
|
||||||
|
`{#each}`). Each one is annotated in `eslint.config.js` with why, and the
|
||||||
|
intended end state is `error`. Drive them down; do not delete them.
|
||||||
|
|
||||||
|
`no-console` is switched **off** for now — see the note in `eslint.config.js`.
|
||||||
|
|
||||||
|
## Git Hooks
|
||||||
|
|
||||||
|
### `install-hooks.sh`
|
||||||
|
Point git at the repo's tracked hooks directory (`core.hooksPath`).
|
||||||
|
```bash
|
||||||
|
bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hooks/pre-commit`
|
||||||
|
Runs the fast half of CLAUDE.md's "Before Committing" list so it is enforced
|
||||||
|
rather than remembered:
|
||||||
|
|
||||||
|
- `bun run check` (svelte-check)
|
||||||
|
- `bun run test` (vitest, single pass)
|
||||||
|
- `scripts/check-frontend-boundary.sh`
|
||||||
|
- `cargo fmt --all -- --check`, **only when staged files touch `src-tauri/`**
|
||||||
|
|
||||||
|
`cargo clippy` and `cargo test` are deliberately *not* in the hook — minutes per
|
||||||
|
commit is how you teach people to reach for `--no-verify`. They run in CI, and
|
||||||
|
locally via `bun run test:all`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --no-verify # skip the hook for one commit
|
||||||
|
git config --unset core.hooksPath # uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook skips itself during a merge, rebase, or cherry-pick, and when nothing
|
||||||
|
is staged.
|
||||||
|
|
||||||
## Utility Scripts
|
## Utility Scripts
|
||||||
|
|
||||||
### `clean.sh`
|
### `clean.sh`
|
||||||
@@ -132,8 +200,12 @@ Clean all build artifacts.
|
|||||||
|
|
||||||
You can also run these via npm/bun:
|
You can also run these via npm/bun:
|
||||||
```bash
|
```bash
|
||||||
|
bun run test # Frontend tests (single pass)
|
||||||
bun run test:all # All tests
|
bun run test:all # All tests
|
||||||
bun run test:rust # Rust tests
|
bun run test:rust # Rust tests
|
||||||
|
bun run lint # ESLint
|
||||||
|
bun run format:check # Prettier (check only)
|
||||||
|
bun run hooks:install # Install the git hooks
|
||||||
bun run android:build # Build Android APK
|
bun run android:build # Build Android APK
|
||||||
bun run android:deploy # Deploy to device
|
bun run android:deploy # Deploy to device
|
||||||
bun run android:dev # Build + deploy debug
|
bun run android:dev # Build + deploy debug
|
||||||
|
|||||||
+6
-2
@@ -7,7 +7,9 @@ echo "🧪 Running all tests..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun run test --run
|
# `bun run test` is `vitest run` (single pass). It used to be bare `vitest`,
|
||||||
|
# which needed an explicit `--run` here to avoid parking CI in watch mode.
|
||||||
|
bun run test
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🦀 Running Rust tests..."
|
echo "🦀 Running Rust tests..."
|
||||||
@@ -19,7 +21,9 @@ echo ""
|
|||||||
echo "🚧 Checking architectural gates..."
|
echo "🚧 Checking architectural gates..."
|
||||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||||
bun run check:boundary
|
bun run check:boundary
|
||||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
# Traceability coverage (DR-093): fails below the ratchet in
|
||||||
|
# .gitea/workflows/traceability-check.yml (MIN_THRESHOLD, currently 88%), or
|
||||||
|
# above 100% (miscount).
|
||||||
bun run traces:coverage
|
bun run traces:coverage
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Run frontend tests only
|
# Run frontend tests only.
|
||||||
|
#
|
||||||
|
# `bun run test` is a single pass (`vitest run`), which is what CI and the
|
||||||
|
# pre-commit hook want. This wrapper keeps the interactive modes reachable:
|
||||||
|
# pass --watch or --ui and vitest is invoked in its long-running form instead.
|
||||||
|
# Any other arguments (test-name filters, path filters, --reporter, ...) are
|
||||||
|
# forwarded to the single-pass run.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun run test "$@"
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--watch | --ui | -w)
|
||||||
|
exec bunx vitest "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exec bunx vitest run "$@"
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
|
|
||||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
||||||
|
// idiom, not a stray expression.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
bottomGap;
|
bottomGap;
|
||||||
measure();
|
measure();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
// A new item in the same slot (scrolling a virtualised list, switching series)
|
// A new item in the same slot (scrolling a virtualised list, switching series)
|
||||||
// must drop the previous item's optimistic state or it shows the wrong tick.
|
// must drop the previous item's optimistic state or it shows the wrong tick.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Bare read: registers `itemId` as a dependency of this effect. Svelte 5
|
||||||
|
// idiom, not a stray expression.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
itemId;
|
itemId;
|
||||||
optimistic = null;
|
optimistic = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const done = (fired: boolean) => {
|
const done = (fired: boolean) => {
|
||||||
el.removeEventListener(event, listener);
|
el.removeEventListener(event, listener);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -268,7 +267,9 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
};
|
};
|
||||||
const listener = () => done(true);
|
const listener = () => done(true);
|
||||||
el.addEventListener(event, listener);
|
el.addEventListener(event, listener);
|
||||||
timer = setTimeout(() => done(false), timeoutMs);
|
// `done` closes over `timer`, but can only run once the listener fires or
|
||||||
|
// the timeout elapses — both strictly after this assignment.
|
||||||
|
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function getDeviceId(): Promise<string> {
|
|||||||
return deviceId;
|
return deviceId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[deviceId] Failed to get device ID from backend:", e);
|
console.error("[deviceId] Failed to get device ID from backend:", e);
|
||||||
throw new Error("Failed to initialize device ID: " + String(e));
|
throw new Error("Failed to initialize device ID: " + String(e), { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
switch (state) {
|
switch (state) {
|
||||||
case "playing":
|
case "playing":
|
||||||
case "paused":
|
case "paused":
|
||||||
case "loading":
|
case "loading": {
|
||||||
// When local playback starts, ensure mode is set to local
|
// When local playback starts, ensure mode is set to local
|
||||||
const mode = get(playbackMode);
|
const mode = get(playbackMode);
|
||||||
if (mode.mode !== "local") {
|
if (mode.mode !== "local") {
|
||||||
@@ -233,9 +233,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "idle":
|
case "idle":
|
||||||
case "stopped":
|
case "stopped": {
|
||||||
player.setIdle();
|
player.setIdle();
|
||||||
// When local playback stops, revert to idle mode
|
// When local playback stops, revert to idle mode
|
||||||
const currentMode = get(playbackMode);
|
const currentMode = get(playbackMode);
|
||||||
@@ -245,6 +246,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface InvokeCall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let invokeHistory: InvokeCall[] = [];
|
let invokeHistory: InvokeCall[] = [];
|
||||||
let invokeResponses: Map<string, any> = new Map();
|
const invokeResponses: Map<string, any> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock invoke function that captures calls
|
* Mock invoke function that captures calls
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function validateUrlPathSegment(segment: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reject path separators and null bytes
|
// Reject path separators and null bytes
|
||||||
if (/[\/\\%]/.test(segment)) {
|
if (/[/\\%]/.test(segment)) {
|
||||||
throw new Error("Invalid path segment: contains invalid characters");
|
throw new Error("Invalid path segment: contains invalid characters");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
|
|
||||||
let tauriDriver: ChildProcess;
|
|
||||||
|
|
||||||
export const config: WebdriverIO.Config = {
|
|
||||||
specs: ["./e2e/specs/**/*.e2e.ts"],
|
|
||||||
exclude: [],
|
|
||||||
|
|
||||||
// Run tests sequentially to avoid session conflicts
|
|
||||||
maxInstances: 1,
|
|
||||||
|
|
||||||
port: 4444, // tauri-driver default port
|
|
||||||
hostname: "localhost",
|
|
||||||
|
|
||||||
capabilities: [
|
|
||||||
{
|
|
||||||
maxInstances: 1,
|
|
||||||
"tauri:options": {
|
|
||||||
application: getTauriApplicationPath(),
|
|
||||||
// Use a separate test data directory
|
|
||||||
env: {
|
|
||||||
JELLYTAU_DATA_DIR: path.join(os.tmpdir(), "jellytau-test-data"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as WebdriverIO.Capabilities,
|
|
||||||
],
|
|
||||||
|
|
||||||
logLevel: "warn", // Reduce log noise
|
|
||||||
bail: 0,
|
|
||||||
waitforTimeout: 10000,
|
|
||||||
connectionRetryTimeout: 120000,
|
|
||||||
connectionRetryCount: 3,
|
|
||||||
|
|
||||||
framework: "mocha",
|
|
||||||
reporters: ["spec"],
|
|
||||||
|
|
||||||
mochaOpts: {
|
|
||||||
ui: "bdd",
|
|
||||||
timeout: 60000,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Start tauri-driver before session
|
|
||||||
onPrepare: async function () {
|
|
||||||
tauriDriver = spawn(
|
|
||||||
path.resolve(os.homedir(), ".cargo", "bin", "tauri-driver"),
|
|
||||||
[],
|
|
||||||
{ stdio: [null, process.stdout, process.stderr] }
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 1000); // Give tauri-driver time to start
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Clean up tauri-driver after session
|
|
||||||
onComplete: async function () {
|
|
||||||
tauriDriver.kill();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTauriApplicationPath(): string {
|
|
||||||
const platform = process.platform;
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
// Check for release build first, fallback to debug
|
|
||||||
if (platform === "win32") {
|
|
||||||
const releasePath = path.join(cwd, "src-tauri/target/release/jellytau.exe");
|
|
||||||
const debugPath = path.join(cwd, "src-tauri/target/debug/jellytau.exe");
|
|
||||||
if (fs.existsSync(releasePath)) return releasePath;
|
|
||||||
if (fs.existsSync(debugPath)) return debugPath;
|
|
||||||
return releasePath; // Return default if neither exists
|
|
||||||
} else if (platform === "darwin") {
|
|
||||||
const releasePath = path.join(cwd, "src-tauri/target/release/bundle/macos/jellytau.app");
|
|
||||||
const debugPath = path.join(cwd, "src-tauri/target/debug/bundle/macos/jellytau.app");
|
|
||||||
if (fs.existsSync(releasePath)) return releasePath;
|
|
||||||
if (fs.existsSync(debugPath)) return debugPath;
|
|
||||||
return releasePath;
|
|
||||||
} else {
|
|
||||||
// Linux
|
|
||||||
const releasePath = path.join(cwd, "src-tauri/target/release/jellytau");
|
|
||||||
const debugPath = path.join(cwd, "src-tauri/target/debug/jellytau");
|
|
||||||
if (fs.existsSync(releasePath)) return releasePath;
|
|
||||||
if (fs.existsSync(debugPath)) return debugPath;
|
|
||||||
return debugPath; // Return debug path as default for Linux
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user