diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..cf3a90c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,87 @@ +name: Deploy Configurator to Pages + +on: + push: + branches: [main] + paths: + - 'configurator/**' + - '.github/workflows/pages.yml' + pull_request: + paths: + - 'configurator/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# PR pushes cancel each other (avoid wasting runners on stale commits); +# main-branch runs queue serially under the shared `pages` group so +# Pages deploys never overlap. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('pages-pr-{0}', github.ref) || 'pages' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: configurator + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + # Pinned to 20.19.0 (not bare '20') because vite@8 declares + # engines.node = '^20.19.0 || >=22.12.0'. A runner image stuck on + # an earlier 20.x would fail npm ci with EBADENGINE. + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: configurator/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test (unit) + run: npm test + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Test (e2e) + run: npm run test:e2e + + - name: Build + run: npm run build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: configurator/dist + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: configurator/playwright-report + retention-days: 7 + + deploy: + # Only deploy on push-to-main; PR runs validate the build but must not + # publish a PR's contents to the live site. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ed3c8dc..ef8260a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ ImageJournal.md docker-compose*.yml qnap.docker.*.txt +.playwright-mcp/ diff --git a/configurator/.gitignore b/configurator/.gitignore new file mode 100644 index 0000000..9cff3d8 --- /dev/null +++ b/configurator/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.DS_Store +.vite/ +/playwright-report/ +/test-results/ +/.playwright-mcp/ diff --git a/configurator/README.md b/configurator/README.md new file mode 100644 index 0000000..015d8b5 --- /dev/null +++ b/configurator/README.md @@ -0,0 +1,89 @@ +# RoonServer Docker Configuration Generator + +A static web app that generates `docker-compose.yml` and `docker run` +commands for the official RoonServer Docker image. Built with TypeScript +and Vite, deployed to GitHub Pages. + +## Development + +```sh +npm install +npm run dev # dev server with hot reload at http://localhost:5173 +npm run typecheck # tsc --noEmit +npm test # vitest +npm run build # typecheck + Vite build into dist/ +npm run preview # serve the built dist/ for a final sanity check +``` + +## Adding a platform + +Platforms are declarative JSON under [`public/platforms/`](public/platforms/). +To add one: + +1. Create `public/platforms/.json` matching the shape below. +2. Add the `` to the `public/platforms/index.json` manifest in the + order it should appear in the dropdown. +3. Run `npm test` — the platform-file tests will validate the shape and + confirm the manifest is in sync. + +The manifest's **first visible entry is the default platform**. To change +the default, reorder the manifest. + +### Platform JSON shape + +```json +{ + "id": "qnap", + "label": "QNAP", + "roon": "/share/Container/roon", + "music": "/share/Music", + "backup": "/share/Container/roon-backups", + "prefix": "/share/", + "hint": "Paths set for QNAP. Container Station stores app data under /share/Container/.", + "rootPattern": "^/share/", + "hidden": false +} +``` + +| Field | Required | Purpose | +|---------------|----------|----------------------------------------------------------------------------| +| `id` | yes | Must match the filename (`qnap.json` → `"id": "qnap"`). | +| `label` | yes | Display text in the dropdown and inline warnings. | +| `roon` | yes | Default host path for the `/Roon` container mount. | +| `music` | yes | Default host path for the `/Music` container mount. | +| `backup` | yes | Default host path for the `/RoonBackups` container mount. | +| `prefix` | yes | Placeholder text for the "add mount" host input. | +| `hint` | yes | One-sentence help text shown under the platform dropdown. | +| `rootPattern` | no | Regex string. If present, host paths not matching it show a soft warning. | +| `hidden` | no | `true` keeps the platform loaded but omits it from the dropdown. | + +## Tests + +- **`src/*.test.ts`** — unit tests for the pure modules (`generator`, + `platforms`), plus structural checks on every platform JSON file. Uses + [Vitest](https://vitest.dev/). +- **`e2e/`** — end-to-end tests against the built site, driven by + [Playwright](https://playwright.dev/). Run with `npm run test:e2e`. + +CI runs `typecheck`, `test`, and `test:e2e` before every deploy. + +## Architecture + +Each source module has one job. Keeping them separate makes the pure +logic unit-testable without a DOM. + +| File | Responsibility | +|------------------------|------------------------------------------------------------------| +| `src/types.ts` | Shared types: `Config`, `Platform`, `ValidationIssue`. | +| `src/generator.ts` | Pure functions: `Config` → compose/run output lines. | +| `src/platforms.ts` | Platform loader + validation helpers. No DOM. | +| `src/highlight.ts` | DOM-based syntax highlighting for the output editor. | +| `src/main.ts` | DOM wiring, event handlers, app init. | +| `public/platforms/` | Platform data files (copied verbatim to the build output). | + +## Deployment + +Deployment is automated via [`.github/workflows/pages.yml`](../.github/workflows/pages.yml). +Every push to `main` that touches `configurator/**` triggers a build +and, on success, a Pages deploy. To deploy, the repository's **Settings +→ Pages → Source** must be set to **GitHub Actions**. diff --git a/configurator/e2e/configurator.spec.ts b/configurator/e2e/configurator.spec.ts new file mode 100644 index 0000000..e1510d0 --- /dev/null +++ b/configurator/e2e/configurator.spec.ts @@ -0,0 +1,313 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Pulls the generated config as plain text by walking the output table's +// code cells, which is closer to what the Copy button produces than innerText. +async function getOutput(page: Page): Promise { + return page.evaluate(() => { + const rows = document.querySelectorAll('#output tr'); + return Array.from(rows) + .map((r) => (r.querySelectorAll('td')[1]?.textContent ?? '')) + .join('\n'); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Platforms load asynchronously — wait for the select to have options. + await page.waitForFunction( + () => (document.getElementById('platform-select') as HTMLSelectElement)?.options.length > 0, + ); +}); + +test.describe('Initial state', () => { + test('defaults to QNAP and populates its paths', async ({ page }) => { + await expect(page.locator('#platform-select')).toHaveValue('qnap'); + await expect(page.locator('#vol-roon')).toHaveValue('/share/Container/roon'); + await expect(page.locator('#vol-music')).toHaveValue('/share/Music'); + await expect(page.locator('#vol-backup')).toHaveValue('/share/Container/roon-backups'); + }); + + test('platform dropdown lists all visible platforms in expected order', async ({ page }) => { + const values = await page + .locator('#platform-select option') + .evaluateAll((els) => els.map((e) => (e as HTMLOptionElement).value)); + expect(values).toEqual(['qnap', 'synology', 'unraid', 'truenas', 'linux', 'custom']); + }); + + test('Copy and Download are enabled with no input errors', async ({ page }) => { + await expect(page.locator('#btn-copy')).toBeEnabled(); + await expect(page.locator('#btn-download')).toBeEnabled(); + }); +}); + +test.describe('Platform switching', () => { + test('switching to Synology swaps all three volume paths', async ({ page }) => { + await page.locator('#platform-select').selectOption('synology'); + await expect(page.locator('#vol-roon')).toHaveValue('/volume1/docker/roon'); + await expect(page.locator('#vol-music')).toHaveValue('/volume1/music'); + await expect(page.locator('#vol-backup')).toHaveValue('/volume1/roon-backups'); + expect(await getOutput(page)).toContain('- /volume1/music:/Music'); + }); +}); + +test.describe('CIFS toggle', () => { + test('compose output includes cap_add and both LSM opt-outs in security_opt', async ({ page }) => { + // The is sr-only (visually hidden) and the styled toggle track + // is its visible sibling. Click the wrapping