Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
76 changes: 76 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 39 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 172 additions & 0 deletions tests/e2e/export-menu.spec.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading