test(frontend): add browser tests, starting with the export menu (#365)

Implements #339. CI ran one check on static/js -- a syntax parse -- so any
behavioural regression shipped unnoticed until a user hit it. #335 is the
case in point: "Export All Stems" became permanently unclickable after a
single export, for every track, until the app restarted. It shipped in
alpha 15 and a user found it.

Setup is Playwright against the real backend. tests/e2e/serve.sh seeds a
throwaway jobs directory with one finished track and execs uvicorn
against it, with every data path redirected, so a run cannot read or
touch a developer's library. Only the separation pipeline is skipped;
the endpoints, the registry and the Range requests for stems are real.

Two details in the fixtures are load-bearing, both learned by getting
them wrong first:

- The sidebar renders from the library store, not /api/jobs. A job on
  disk but absent from the store is invisible in the UI, and a test that
  clicks nothing passes for the wrong reason.
- stubTauri installs a controllable window.__TAURI__ so the desktop code
  path runs. This is the point of the exercise: #335 was invisible in a
  browser, because there the synthetic <a>.click() closes the chip panel
  before the busy state is applied and the bug hides. The stub also
  leaves save_audio_file pending until the test settles it, so the busy
  state machine is driven rather than raced.

Nine tests cover all four defects from #335 and #337: rows re-enabled
after an export in both host modes, a second export still working, the
busy state waiting on the save rather than a timer, failures surfacing
and recovering, and export errors not offering a "Try again" that sends
the user to the URL import field.

Verified by reintroducing each defect and watching the suite fail:
clearing only the visible rows on reset fails 3 tests (the panel is
closed by then, so a visibility-filtered clear clears nothing), the
fixed-timer reset fails 1, and the retry button fails 1.

MP4 format switching is only partly covered. The video format is hidden
unless the track has one, and a video fixture is its own piece of work,
so what is here pins that MP4 is not offered for audio-only tracks.
Beat grid and transport coverage remain open on #339.

Closes #339
This commit is contained in:
Tha.Les
2026-08-12 20:18:48 +01:00
committed by GitHub
parent 5565b216ba
commit 1e1cf0bbce
9 changed files with 631 additions and 0 deletions
+26
View File
@@ -50,6 +50,32 @@ jobs:
- run: for f in static/js/*.js; do node --check "$f"; done
- run: for f in tests/js/*.test.mjs; do node "$f"; done
# Browser tests for static/js (#339). node --check above is a syntax parse;
# this drives the real UI against the real backend, including the Tauri code
# path, which is where #335 hid because it was invisible in a browser.
frontend-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.0.0
with:
node-version: "20"
cache: npm
- run: curl -LsSf https://astral.sh/uv/install.sh | sh
- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: uv sync --frozen --all-extras
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- if: failure()
uses: actions/upload-artifact@v7.0.1
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
# The Linux installer only ever runs on a user's machine, so nothing else
# would catch a regression in it. Runs on a real Linux image rather than the
# macOS bash used during development.
+6
View File
@@ -24,6 +24,12 @@ static/version.json
# Desktop dependencies and build outputs
desktop/node_modules/
node_modules/
# Playwright browser-test output (tests/e2e)
test-results/
playwright-report/
blob-report/
desktop/src-tauri/target/
desktop/src-tauri/gen/
+76
View File
@@ -0,0 +1,76 @@
{
"name": "stemdeck-frontend-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stemdeck-frontend-tests",
"devDependencies": {
"@playwright/test": "^1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "stemdeck-frontend-tests",
"private": true,
"type": "module",
"description": "Browser tests for static/js. The app itself has no build step and no runtime dependencies; this exists only so CI can drive a real browser (#339).",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:js": "for f in tests/js/*.test.mjs; do node \"$f\"; done"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
// The backend is real: these tests exercise the actual endpoints, with only the
// separation pipeline skipped (tests/e2e/seed.py writes a finished job instead
// of running demucs to test a menu).
//
// serve.sh seeds a throwaway jobs directory and execs uvicorn against it, so a
// run can never see or touch a developer's real library.
const PORT = process.env.STEMDECK_E2E_PORT || "8123";
export default defineConfig({
testDir: "tests/e2e",
testMatch: /.*\.spec\.mjs/,
// A stuck export used to hang for 15 minutes by design (EXPORT_BUSY_MAX_MS),
// so a generous per-test timeout would hide exactly the bug these tests exist
// to catch.
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["github"]] : [["list"]],
use: {
baseURL: `http://127.0.0.1:${PORT}`,
trace: process.env.CI ? "retain-on-failure" : "off",
screenshot: "only-on-failure",
video: "off",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `bash tests/e2e/serve.sh ${PORT}`,
url: `http://127.0.0.1:${PORT}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
});
+172
View File
@@ -0,0 +1,172 @@
// Regression tests for the export menu (#335, #337).
//
// #335: "Export All Stems" became permanently unclickable after one export, for
// every track, until the app restarted. It shipped in alpha 15 and a user found
// it. It reproduced only in the desktop build, because in a browser the
// synthetic <a>.click() closes the chip panel before the busy state is applied
// and the bug hides -- so the Tauri-mode cases below are the ones that matter.
//
// #337 fixed it and turned up three more defects in the same state machine,
// each covered here.
import { test, expect } from "@playwright/test";
import { openStudio, exportUi } from "./helpers.mjs";
test.describe("export menu, desktop (Tauri) mode", () => {
test("every row is usable again after an export completes", async ({ page }) => {
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await ui.stems.click();
// Mid-export: the menu is busy and says so.
await expect(ui.label).toHaveText(/Exporting/);
await expect(ui.stems).toHaveAttribute("aria-disabled", "true");
expect(await page.evaluate(() => window.__e2e.savePending())).toBe(true);
await page.evaluate(() => window.__e2e.finishSave());
// #335 itself: without a symmetric reset this row stays disabled forever.
await expect(ui.label).toHaveText("Export Mix");
await expect(ui.stems).not.toHaveAttribute("aria-disabled", "true");
await expect(ui.mix).not.toHaveAttribute("aria-disabled", "true");
});
test("a second export still works after the first", async ({ page }) => {
await openStudio(page, { tauri: true });
const ui = exportUi(page);
for (const pass of [1, 2]) {
await ui.open();
await ui.stems.click();
await expect(ui.label).toHaveText(/Exporting/, { timeout: 5000 });
await page.evaluate(() => window.__e2e.finishSave());
await expect(ui.label).toHaveText("Export Mix");
expect(
await page.evaluate(() => window.__e2e.callsFor("save_audio_file").length),
`save_audio_file should have fired on pass ${pass}`,
).toBe(pass);
}
});
test("the busy state waits for the save, not a fixed timer", async ({ page }) => {
// #337: the reset used to run on a timer, so a slow save looked finished
// while it was still writing, and a failed one looked identical to success.
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await ui.mix.click();
await expect(ui.label).toHaveText(/Exporting/);
await page.waitForTimeout(3000); // comfortably past the 1200 ms guess
await expect(ui.label).toHaveText(/Exporting/);
expect(await page.evaluate(() => window.__e2e.savePending())).toBe(true);
await page.evaluate(() => window.__e2e.finishSave());
await expect(ui.label).toHaveText("Export Mix");
});
test("a failed export says so and leaves the menu usable", async ({ page }) => {
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await ui.mix.click();
await expect(ui.label).toHaveText(/Exporting/);
await page.evaluate(() => window.__e2e.failSave("disk full"));
await expect(ui.error).toBeVisible();
await expect(ui.error).toContainText(/disk full/i);
// The state machine has to recover from the failure, not just report it.
await expect(ui.label).toHaveText("Export Mix");
await expect(ui.mix).not.toHaveAttribute("aria-disabled", "true");
});
test("an export failure does not offer to retry the import", async ({ page }) => {
// #337: export errors reused the import error box, whose "Try again" button
// sends the user to the URL field -- which has nothing to do with a failed
// save and loses the studio they were working in.
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await ui.mix.click();
await page.evaluate(() => window.__e2e.failSave("nope"));
await expect(ui.error).toBeVisible();
await expect(ui.error).toContainText("Dismiss");
await expect(ui.error).not.toContainText("Try again");
});
});
test.describe("export menu, browser mode", () => {
test("rows recover after the fire-and-forget download path", async ({ page }) => {
// No Tauri bridge: _triggerDownload falls back to a synthetic <a download>,
// which reports nothing back, so the reset runs on the timer instead.
await openStudio(page);
const ui = exportUi(page);
await ui.open();
await ui.stems.click();
await expect(ui.label).toHaveText("Export Mix", { timeout: 8000 });
await ui.open();
await expect(ui.stems).not.toHaveAttribute("aria-disabled", "true");
});
});
test.describe("format switching", () => {
test("picking a format updates the radio group", async ({ page }) => {
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await expect(ui.fmt("wav")).toHaveAttribute("aria-checked", "true");
await ui.fmt("flac").click();
await expect(ui.fmt("flac")).toHaveAttribute("aria-checked", "true");
await expect(ui.fmt("wav")).toHaveAttribute("aria-checked", "false");
await expect(ui.fmt("flac")).toHaveClass(/active/);
});
test("MP4 is not offered for a track with no video", async ({ page }) => {
// The video format only appears once the track actually has one
// (#footer-export-wrap.has-video). Offering it otherwise produces an export
// that cannot succeed.
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await expect(ui.fmt("mp4")).toBeHidden();
await expect(ui.stems).toBeVisible();
});
});
test.describe("busy state", () => {
test("the menu cannot be reopened mid-export", async ({ page }) => {
// flashBusy closes the panel and the button ignores clicks while busy, so
// there is no way to change format or fire a second export underneath the
// first. This is what makes the "hidden row left disabled" case unreachable
// from the UI; the reset clears every row regardless.
await openStudio(page, { tauri: true });
const ui = exportUi(page);
await ui.open();
await ui.mix.click();
await expect(ui.label).toHaveText(/Exporting/);
await expect(ui.panel).toHaveClass(/hidden/);
await ui.button.click();
await expect(ui.panel).toHaveClass(/hidden/);
await page.evaluate(() => window.__e2e.finishSave());
await expect(ui.label).toHaveText("Export Mix");
// ...and it works again immediately afterwards.
await ui.open();
await expect(ui.panel).not.toHaveClass(/hidden/);
});
});
+146
View File
@@ -0,0 +1,146 @@
// Shared setup for the browser tests.
//
// Two things here are load-bearing and were both learned the hard way:
//
// 1. The sidebar renders from the library store, not from /api/jobs. A job that
// exists on disk but is absent from the store is invisible in the UI, and a
// test that clicks nothing passes for the wrong reason. seedLibrary writes
// that store before any script runs.
//
// 2. The desktop and browser download paths genuinely diverge, which is why
// #335 was invisible in a browser. stubTauri installs a controllable
// window.__TAURI__ so the desktop branch runs, and so the test decides when
// an export finishes rather than racing a real one.
export const JOB_ID = "e2e0deadbeef";
export const TRACK_TITLE = "E2E Fixture Track";
const STORAGE_KEY = "stemdeck.folders";
const STORAGE_VERSION = 2;
/** Put the fixture track in the library so the sidebar renders it. */
export async function seedLibrary(page) {
const state = {
v: STORAGE_VERSION,
folders: [
{ id: "f-unsorted", name: "Unsorted", items: [JOB_ID], color: null },
{ id: "trash", name: "Trash", items: [], color: null },
],
tracks: {
[JOB_ID]: {
id: JOB_ID,
title: TRACK_TITLE,
status: "done",
stems: ["vocals", "drums", "bass", "other"],
sourceUrl: "local:e2e-fixture.wav",
createdAt: 1700000000,
favorite: false,
},
},
};
await page.addInitScript(
([key, value]) => window.localStorage.setItem(key, JSON.stringify(value)),
[STORAGE_KEY, state],
);
}
/**
* Install a fake Tauri bridge so the app takes its desktop code path.
*
* `save_audio_file` is left pending until the test resolves or rejects it,
* which is the whole point: the export busy state is a promise state machine,
* and #335 was a stuck one. Tests drive it through window.__e2e.
*/
export async function stubTauri(page) {
await page.addInitScript(() => {
const calls = [];
let pendingResolve = null;
let pendingReject = null;
window.__e2e = {
calls,
// Settle the export that is currently in flight.
finishSave: (value) => pendingResolve && pendingResolve(value ?? null),
failSave: (message) => pendingReject && pendingReject(message ?? "save failed"),
savePending: () => Boolean(pendingResolve),
callsFor: (cmd) => calls.filter((c) => c.cmd === cmd),
};
window.__TAURI__ = {
core: {
invoke: (cmd, args) => {
calls.push({ cmd, args });
switch (cmd) {
case "save_audio_file":
return new Promise((resolve, reject) => {
pendingResolve = (v) => { pendingResolve = null; pendingReject = null; resolve(v); };
pendingReject = (e) => { pendingResolve = null; pendingReject = null; reject(e); };
});
// The library store lives in the Tauri store on desktop. Back it
// with localStorage so seedLibrary works in this mode too.
case "store_get": {
const raw = window.localStorage.getItem(args?.key);
return Promise.resolve(raw === null ? null : JSON.parse(raw));
}
case "store_set":
window.localStorage.setItem(args?.key, JSON.stringify(args?.value));
return Promise.resolve(null);
case "get_setup_status":
return Promise.resolve({ ready: true, data_dir: "/tmp/e2e", ffmpeg: "/usr/bin/ffmpeg" });
default:
return Promise.resolve(null);
}
},
},
event: { listen: () => Promise.resolve(() => {}) },
};
});
}
/**
* Keep export requests off the real backend.
*
* A browser-mode export is an <a download> pointed at a mixdown endpoint that
* shells out to ffmpeg. These tests are about the menu's state machine, so the
* bytes are irrelevant and the render time is not worth paying.
*/
export async function stubExportEndpoints(page) {
await page.route("**/api/jobs/*/mix**", (route) =>
route.fulfill({ status: 200, contentType: "audio/wav", body: Buffer.from("RIFF") }));
await page.route("**/api/jobs/*/stems.zip**", (route) =>
route.fulfill({ status: 200, contentType: "application/zip", body: Buffer.from("PK") }));
await page.route("**/api/jobs/*/render**", (route) =>
route.fulfill({ status: 200, contentType: "audio/wav", body: Buffer.from("RIFF") }));
}
/** Open the fixture track in the studio and wait until the transport is live. */
export async function openStudio(page, { tauri = false } = {}) {
await seedLibrary(page);
if (tauri) await stubTauri(page);
await stubExportEndpoints(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
// The transport total only leaves 00:00 once an engine has reported a
// duration, so it doubles as "the studio is actually ready".
await page.waitForFunction(
() => !/\/\s*00:00\s*$/.test(document.querySelector("#t-time")?.textContent || "00:00 / 00:00"),
null,
{ timeout: 20000 },
);
}
export const exportUi = (page) => ({
button: page.locator("#t-export-btn"),
panel: page.locator("#t-export-panel"),
label: page.locator("#t-export-label"),
mix: page.locator("#t-export-mix"),
stems: page.locator("#t-export-stems"),
region: page.locator("#t-export-region"),
fmt: (name) => page.locator(`#t-fmt-${name}`),
error: page.locator("#error:not(.hidden)"),
open: async () => {
await page.locator("#t-export-btn").click();
await page.locator("#t-export-panel:not(.hidden)").waitFor({ timeout: 5000 });
},
});
+119
View File
@@ -0,0 +1,119 @@
"""Build a jobs directory containing one finished track, for the browser tests.
The point is that the tests talk to the real backend: real Range requests for
stems, the real registry, the real endpoints. Only the pipeline is skipped,
because running demucs to test a menu would be absurd.
The stems are genuine PCM16 WAVs rather than placeholder bytes. The chunked
audio engine parses WAV containers itself, so a file that is not really a WAV
loads with no duration and the studio comes up without playback -- which is the
#358 failure mode, and it would make every test here fail for the wrong reason.
Usage: python tests/e2e/seed.py <jobs-dir>
"""
from __future__ import annotations
import json
import math
import struct
import sys
import time
from pathlib import Path
JOB_ID = "e2e0deadbeef"
TITLE = "E2E Fixture Track"
STEMS = ["vocals", "drums", "bass", "other"]
SAMPLE_RATE = 44100
CHANNELS = 2
DURATION_SEC = 6
def _wav_bytes(freq: float, seconds: int = DURATION_SEC) -> bytes:
"""A short stereo PCM16 tone. Audible content matters: silence would make a
broken mix indistinguishable from a working one if these tests ever grow
real audio assertions."""
frames = SAMPLE_RATE * seconds
body = bytearray()
for i in range(frames):
value = int(12000 * math.sin(2 * math.pi * freq * i / SAMPLE_RATE))
body += struct.pack("<hh", value, value)
block_align = CHANNELS * 2
fmt_chunk = struct.pack(
"<HHIIHH", 1, CHANNELS, SAMPLE_RATE, SAMPLE_RATE * block_align, block_align, 16
)
chunks = b"fmt " + struct.pack("<I", len(fmt_chunk)) + fmt_chunk
chunks += b"data" + struct.pack("<I", len(body)) + bytes(body)
return b"RIFF" + struct.pack("<I", 4 + len(chunks)) + b"WAVE" + chunks
def _peaks(points: int = 400) -> list[list[float]]:
"""Matches what the backend writes: min/max pairs per bucket, so the studio
renders overview waveforms from peaks instead of falling back to decoding
every stem in the browser."""
out = []
for i in range(points):
amp = abs(math.sin(i / 18.0)) * 0.8
out.append([round(-amp, 4), round(amp, 4)])
return out
def seed(jobs_dir: Path) -> str:
job_dir = jobs_dir / JOB_ID
stems_dir = job_dir / "stems"
stems_dir.mkdir(parents=True, exist_ok=True)
for index, name in enumerate(STEMS):
(stems_dir / f"{name}.wav").write_bytes(_wav_bytes(220.0 * (index + 1)))
(job_dir / "peaks.json").write_text(
json.dumps({name: _peaks() for name in STEMS}), encoding="utf-8"
)
(job_dir / "beats.json").write_text(
json.dumps(
{
"bpm": 120.0,
"beats": [round(i * 0.5, 3) for i in range(DURATION_SEC * 2)],
"downbeats": [round(i * 2.0, 3) for i in range(DURATION_SEC // 2)],
}
),
encoding="utf-8",
)
# Field names are the dataclass's, not the API's: from_record filters on
# Job's own fields, so "stage" or "duration" would be silently dropped and
# the track would load without a duration.
record = {
"id": JOB_ID,
"status": "done",
"progress": 1.0,
"stage_message": "Done",
"title": TITLE,
"duration_sec": float(DURATION_SEC),
"source_url": "local:e2e-fixture.wav",
# Now, not a fixed date. The hourly sweep deletes job directories older
# than JOB_TTL_SECONDS, and it runs at startup: a fixture with a
# hardcoded timestamp is reaped before the first test opens the page,
# leaving a registry entry pointing at nothing.
"created_at": time.time(),
"bpm": 120,
"key": "C maj",
"scale": "Major",
# Per-stem RMS as 0-100 ints, which is what drives the presence cards.
"stem_presence": {name: 80 for name in STEMS},
# Same shape the pipeline writes (runner.py): entries, not bare names.
# A list of strings deserialises without error and then leaves the
# studio with nothing to play.
"stems": [{"name": name, "url": f"/api/jobs/{JOB_ID}/stems/{name}.wav"} for name in STEMS],
}
(jobs_dir / "registry.json").write_text(
json.dumps({"version": 1, "jobs": [record]}, indent=2) + "\n", encoding="utf-8"
)
return JOB_ID
if __name__ == "__main__":
target = Path(sys.argv[1]).expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
print(seed(target))
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
#
# Boot a StemDeck backend for the browser tests, against a throwaway jobs
# directory seeded with one finished track.
#
# Everything the app writes is redirected into that directory, so a test run can
# never read, modify or delete a developer's real library. The directory is
# recreated on every run, so state cannot leak between runs either.
set -euo pipefail
PORT="${1:-8123}"
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
WORK_DIR="${STEMDECK_E2E_DIR:-${TMPDIR:-/tmp}/stemdeck-e2e}"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR/jobs" "$WORK_DIR/data"
uv run python "${REPO_ROOT}/tests/e2e/seed.py" "$WORK_DIR/jobs" >/dev/null
cd "$REPO_ROOT"
exec env \
STEMDECK_JOBS_DIR="$WORK_DIR/jobs" \
STEMDECK_DATA_DIR="$WORK_DIR/data" \
STEMDECK_CACHE_DIR="$WORK_DIR/data/cache" \
STEMDECK_LOGS_DIR="$WORK_DIR/data/logs" \
STEMDECK_MODELS_DIR="$WORK_DIR/data/models" \
STEMDECK_DOWNLOADS_DIR="$WORK_DIR/data/downloads" \
uv run uvicorn app.main:app \
--host 127.0.0.1 \
--port "$PORT" \
--log-level warning \
--timeout-graceful-shutdown 2