chore(tooling): add lint/format gates, pin the toolchain, enforce commit checks
Adds the frontend's first linter and formatter — the Rust half has had cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had only svelte-check. ESLint runs clean; 159 findings are recorded as warnings rather than suppressed, so the backlog is visible without painting CI red. Also: `bun run test` no longer drops into watch mode (the "Before Committing" list told people to run a command that never returns), the traceability ratchet moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead of relying on memory, the dead webdriverio e2e suite and its five devDeps are removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and the CI builder image stop being five releases apart. TRACES: | DR-205, DR-206, DR-207
This commit is contained in:
@@ -62,6 +62,13 @@ jobs:
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
# The docs are the maintained source of truth for architecture and
|
||||
# process, and they cross-reference each other heavily. A rename that
|
||||
# misses a link turns a doc into a dead end silently. Pure shell + git —
|
||||
# no tool is installed at job time.
|
||||
- name: Check documentation links
|
||||
run: bash scripts/check-doc-links.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
#
|
||||
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
|
||||
# scripts/extract-traces.test.ts fails if the two drift apart.
|
||||
MIN_THRESHOLD=82
|
||||
MIN_THRESHOLD=88
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
||||
exit 1
|
||||
|
||||
@@ -30,11 +30,6 @@ coverage
|
||||
.nyc_output
|
||||
*.lcov
|
||||
|
||||
# WebdriverIO E2E tests
|
||||
e2e/logs/
|
||||
e2e/screenshots/
|
||||
wdio-*.log
|
||||
|
||||
# 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
-3
@@ -52,13 +52,34 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Install Rust using rustup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
||||
# Install Rust using rustup, pinned to an exact release.
|
||||
#
|
||||
# 🔴 RUST_VERSION must equal `channel` in src-tauri/rust-toolchain.toml.
|
||||
#
|
||||
# The two are a pair. rust-toolchain.toml is what makes a developer's `cargo
|
||||
# clippy` agree with CI's; this line is what makes the image already contain that
|
||||
# toolchain. If they drift, rustup silently downloads the pinned version the
|
||||
# first time cargo runs inside a job — a toolchain install at job time, which
|
||||
# CLAUDE.md's "🔴 CI installs no system tools" rule forbids (and which costs
|
||||
# ~1min plus a network dependency on every build).
|
||||
#
|
||||
# 🔴 Changing this line does NOT change CI on its own: the image must be
|
||||
# rebuilt and pushed (`scripts/build-builder-image.sh`) before the new pin is
|
||||
# authoritative. Bump rust-toolchain.toml and this line together, rebuild, push,
|
||||
# then merge.
|
||||
#
|
||||
# Was: `sh -s -- -y` (latest stable, whatever it happened to be on rebuild day).
|
||||
ENV RUST_VERSION=1.97.1
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain "$RUST_VERSION" && \
|
||||
. $HOME/.cargo/env && \
|
||||
rustup default "$RUST_VERSION" && \
|
||||
rustup target add aarch64-linux-android && \
|
||||
rustup target add armv7-linux-androideabi && \
|
||||
rustup target add x86_64-linux-android && \
|
||||
rustup component add rustfmt clippy
|
||||
rustup component add rustfmt clippy && \
|
||||
rustc --version && \
|
||||
cargo clippy --version
|
||||
|
||||
# Setup Android SDK
|
||||
RUN mkdir -p $ANDROID_HOME && \
|
||||
|
||||
@@ -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",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "wdio run ./wdio.conf.ts",
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:all": "./scripts/test-all.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:links": "bash scripts/check-doc-links.sh",
|
||||
"hooks:install": "./scripts/install-hooks.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:device": "./scripts/build-android.sh --device",
|
||||
@@ -51,6 +56,7 @@
|
||||
"svelte-dnd-action": "^0.9.69"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -59,18 +65,20 @@
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@wdio/cli": "^9.5.0",
|
||||
"@wdio/local-runner": "^9.5.0",
|
||||
"@wdio/mocha-framework": "^9.5.0",
|
||||
"@wdio/spec-reporter": "^9.5.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"happy-dom": "^20.0.11",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.47.1",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0"
|
||||
"vitest": ">=1.0.0 <5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+73
-1
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
|
||||
### `test-frontend.sh`
|
||||
Run frontend tests only.
|
||||
```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 --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`
|
||||
Run Rust tests only.
|
||||
```bash
|
||||
@@ -120,6 +135,59 @@ For details, see:
|
||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||
- [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
|
||||
|
||||
### `clean.sh`
|
||||
@@ -132,8 +200,12 @@ Clean all build artifacts.
|
||||
|
||||
You can also run these via npm/bun:
|
||||
```bash
|
||||
bun run test # Frontend tests (single pass)
|
||||
bun run test:all # All 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:deploy # Deploy to device
|
||||
bun run android:dev # Build + deploy debug
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface TracesData {
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export const MIN_COVERAGE_PERCENT = 82;
|
||||
export const MIN_COVERAGE_PERCENT = 88;
|
||||
|
||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# JellyTau pre-commit hook — the fast half of CLAUDE.md's "Before Committing"
|
||||
# list, enforced instead of remembered.
|
||||
#
|
||||
# TRACES: | DR-207
|
||||
#
|
||||
# Install with: bun run hooks:install (sets core.hooksPath=scripts/hooks)
|
||||
# Skip once with: git commit --no-verify
|
||||
#
|
||||
# What runs here is deliberately limited to gates that finish in seconds:
|
||||
#
|
||||
# bun run check svelte-check (types)
|
||||
# bun run test vitest, single pass
|
||||
# scripts/check-frontend-boundary.sh domain-taxonomy tripwire (DR-094)
|
||||
# cargo fmt --all -- --check only when src-tauri/ is staged
|
||||
#
|
||||
# NOT here, on purpose: `cargo clippy` and `cargo test`. Both take minutes on a
|
||||
# cold target dir, which turns every commit into a coffee break and trains
|
||||
# people to reach for --no-verify. CI (.gitea/workflows/build-and-test.yml) is
|
||||
# where those run; `bun run test:all` is the local equivalent.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Merge and rebase commits carry someone else's changes, and conflict resolution
|
||||
# is exactly when a slow gate is least welcome. Let them through — CI still
|
||||
# gates the merge result.
|
||||
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
|
||||
if [ -e "$GIT_DIR_PATH/MERGE_HEAD" ] ||
|
||||
[ -d "$GIT_DIR_PATH/rebase-merge" ] ||
|
||||
[ -d "$GIT_DIR_PATH/rebase-apply" ] ||
|
||||
[ -e "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ]; then
|
||||
echo "pre-commit: merge/rebase in progress — skipping checks (CI still gates the result)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Nothing staged (e.g. `git commit --amend` that only edits the message): nothing
|
||||
# to check.
|
||||
STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
FAILED=0
|
||||
|
||||
run_gate() {
|
||||
label="$1"
|
||||
shift
|
||||
echo ""
|
||||
echo "🔎 pre-commit: $label"
|
||||
if ! "$@"; then
|
||||
echo "❌ pre-commit: $label failed"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
run_gate "svelte-check (bun run check)" bun run check
|
||||
run_gate "frontend tests (bun run test)" bun run test
|
||||
run_gate "frontend/backend boundary" bash scripts/check-frontend-boundary.sh
|
||||
|
||||
# rustfmt only matters when Rust actually changed, and `cargo fmt --check` is
|
||||
# cheap (no compilation) whenever it does.
|
||||
if printf '%s\n' "$STAGED" | grep -q '^src-tauri/'; then
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "🔎 pre-commit: rustfmt (src-tauri/ is staged)"
|
||||
if ! (cd src-tauri && cargo fmt --all -- --check); then
|
||||
echo "❌ pre-commit: cargo fmt --all -- --check failed"
|
||||
echo " fix with: cd src-tauri && cargo fmt"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "⚠️ pre-commit: src-tauri/ staged but cargo is not on PATH — skipping rustfmt."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "🛑 pre-commit checks failed. Fix them, or bypass deliberately with:"
|
||||
echo " git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ pre-commit checks passed."
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Point git at the repo's tracked hooks directory.
|
||||
#
|
||||
# TRACES: | DR-207
|
||||
#
|
||||
# bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||
#
|
||||
# `core.hooksPath` is used rather than copying files into .git/hooks so the
|
||||
# hooks stay version-controlled: an update to scripts/hooks/pre-commit reaches
|
||||
# everyone on their next pull instead of needing a re-install.
|
||||
#
|
||||
# The setting is local to this clone (git config, not committed). To undo:
|
||||
# git config --unset core.hooksPath
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
HOOKS_DIR="scripts/hooks"
|
||||
|
||||
if [ ! -d "$HOOKS_DIR" ]; then
|
||||
echo "❌ $HOOKS_DIR does not exist — are you in the JellyTau repo?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Git refuses to run a hook that is not executable, and the bit is easy to lose
|
||||
# on a fresh checkout on some filesystems.
|
||||
chmod +x "$HOOKS_DIR"/* 2>/dev/null || true
|
||||
|
||||
git config core.hooksPath "$HOOKS_DIR"
|
||||
|
||||
echo "✅ core.hooksPath = $(git config core.hooksPath)"
|
||||
echo ""
|
||||
echo "Installed hooks:"
|
||||
for hook in "$HOOKS_DIR"/*; do
|
||||
[ -f "$hook" ] || continue
|
||||
echo " - $(basename "$hook")"
|
||||
done
|
||||
echo ""
|
||||
echo "pre-commit runs: bun run check, bun run test, check-frontend-boundary.sh,"
|
||||
echo "and cargo fmt --check when src-tauri/ is staged."
|
||||
echo "Bypass a single commit with: git commit --no-verify"
|
||||
+6
-2
@@ -7,7 +7,9 @@ echo "🧪 Running all tests..."
|
||||
echo ""
|
||||
|
||||
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 "🦀 Running Rust tests..."
|
||||
@@ -19,7 +21,9 @@ echo ""
|
||||
echo "🚧 Checking architectural gates..."
|
||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||
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
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
#!/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
|
||||
|
||||
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 "$@"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Pinned Rust toolchain for the JellyTau backend.
|
||||
#
|
||||
# TRACES: | DR-206
|
||||
#
|
||||
# Why pin: the toolchain was unpinned, so the CI builder image (rustc 1.97.1)
|
||||
# and developer machines (as low as 1.92.0) were five releases apart. Clippy's
|
||||
# lint set and rustfmt's output both move between releases, which means a green
|
||||
# `cargo clippy` / `cargo fmt --check` locally proved nothing about CI — and vice
|
||||
# versa. Everything in this file exists to make both sides run the same compiler.
|
||||
#
|
||||
# 🔴 This value MUST match the rustc that Dockerfile.builder installs (see
|
||||
# RUST_VERSION there). If they drift, rustup downloads the pinned toolchain at
|
||||
# job time inside the container — a toolchain install in CI, which is exactly
|
||||
# what CLAUDE.md's "CI installs no system tools" rule forbids. To move the pin:
|
||||
# bump BOTH this file and Dockerfile.builder, then rebuild and push the image
|
||||
# with scripts/build-builder-image.sh before merging.
|
||||
#
|
||||
# No `targets` key on purpose: listing the Android/Windows targets here would
|
||||
# make rustup fetch all of them on every plain `cargo test`, including on
|
||||
# machines that never cross-compile. The builder image already carries them
|
||||
# (`rustup target add` in Dockerfile.builder), and the cross-build scripts add
|
||||
# them locally when needed.
|
||||
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -12,9 +12,12 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "jellytau",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"title": "JellyTau",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$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;
|
||||
measure();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
// 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.
|
||||
$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;
|
||||
optimistic = null;
|
||||
});
|
||||
|
||||
@@ -263,7 +263,6 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const done = (fired: boolean) => {
|
||||
el.removeEventListener(event, listener);
|
||||
clearTimeout(timer);
|
||||
@@ -271,7 +270,9 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
};
|
||||
const listener = () => done(true);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function getDeviceId(): Promise<string> {
|
||||
return deviceId;
|
||||
} catch (e) {
|
||||
log.error("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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
switch (state) {
|
||||
case "playing":
|
||||
case "paused":
|
||||
case "loading":
|
||||
case "loading": {
|
||||
// When local playback starts, ensure mode is set to local
|
||||
const mode = get(playbackMode);
|
||||
if (mode.mode !== "local") {
|
||||
@@ -236,9 +236,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "idle":
|
||||
case "stopped":
|
||||
case "stopped": {
|
||||
player.setIdle();
|
||||
// When local playback stops, revert to idle mode
|
||||
const currentMode = get(playbackMode);
|
||||
@@ -250,6 +251,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media loaded event.
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface InvokeCall {
|
||||
}
|
||||
|
||||
let invokeHistory: InvokeCall[] = [];
|
||||
let invokeResponses: Map<string, any> = new Map();
|
||||
const invokeResponses: Map<string, any> = new Map();
|
||||
|
||||
/**
|
||||
* Mock invoke function that captures calls
|
||||
|
||||
@@ -79,7 +79,7 @@ export function validateUrlPathSegment(segment: string): void {
|
||||
}
|
||||
|
||||
// Reject path separators and null bytes
|
||||
if (/[\/\\%]/.test(segment)) {
|
||||
if (/[/\\%]/.test(segment)) {
|
||||
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