From 0729596e8c75875cd20f96bd547e0d31591ef0d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 08:23:18 +0000 Subject: [PATCH 1/5] feat(developer-tools): add Developer menu with Style Lab, iDevice Lab, REST API Add a dev-only Developer dropdown to the workarea navbar containing three entries: Style Lab, iDevice Lab, and REST API. The REST API entry is moved out of the (hidden) Help menu so the canonical Swagger access point lives here. All routes return 404 in production; visibility is gated on APP_ENV=dev with an optional DEV_TOOLS_ENABLED override for staging. What is in this MVP: * Backend: src/utils/developer-tools.util.ts (isDeveloperToolsEnabled + DeveloperToolEntries) and src/routes/developer.ts wiring /developer/style-lab, /developer/idevice-lab, /developer/api into both the root and BASE_PATH mounts. Both files have full spec coverage. * Shared frontend kernel under public/app/workarea/developer/shared/: DeveloperUrlState (sanitized query-param state), ViewportManager (desktop/tablet/mobile presets), FixtureRegistry, ExportPresetManager, DeveloperStatusReporter (data-status + JSON state), RoundtripValidator (save -> load -> save diff). Every module has colocated Vitest specs. * Style Lab + iDevice Lab pages with stable data-testid attributes, deterministic URL state, machine-readable state JSON, and Page Objects for Playwright at test/e2e/playwright/pages/developer.page.ts. * doc/development/developer-tools.md documents architecture, automation surface, fixture manifests, and known limitations. Reused assets: fixture manifest entries reference .elpx files from the authorized exelearning-style-designer repository; the actual fixtures are not committed here (placeholders + README explaining where they go). Known limitations (called out in the doc + UI): * Style Lab preview iframe is wired but not yet hooked to Html5Exporter.generateForPreview() + Service Worker. The integration point is DeveloperPreviewManager (next PR). * iDevice Lab edition/export views render their containers; the iDevice mount step lives in IdeviceSandbox follow-up work. * SCORM debug panel ships as a labeled simulator only. * Reload-from-disk emits a state token; backend filesystem reload route is the next step. Test plan: * bun test ./src/routes/developer.spec.ts ./src/utils/developer-tools.util.spec.ts -> 23 pass, 0 fail * npx vitest run public/app/workarea/developer/ -> 104 pass, 0 fail * bun test ./src/routes/pages.spec.ts -> 86 pass (no regression) * make lint -> clean --- doc/development/developer-tools.md | 255 ++++++++++++++++ playwright.config.ts | 1 + public/app/workarea/developer/developer.css | 224 ++++++++++++++ .../developer/idevice-lab/IdeviceLab.js | 287 ++++++++++++++++++ .../developer/idevice-lab/IdeviceLab.test.js | 189 ++++++++++++ .../idevice-lab/samples.manifest.json | 32 ++ .../shared/DeveloperStatusReporter.js | 105 +++++++ .../shared/DeveloperStatusReporter.test.js | 117 +++++++ .../developer/shared/DeveloperUrlState.js | 206 +++++++++++++ .../shared/DeveloperUrlState.test.js | 194 ++++++++++++ .../developer/shared/ExportPresetManager.js | 104 +++++++ .../shared/ExportPresetManager.test.js | 86 ++++++ .../developer/shared/FixtureRegistry.js | 57 ++++ .../developer/shared/FixtureRegistry.test.js | 75 +++++ .../developer/shared/RoundtripValidator.js | 152 ++++++++++ .../shared/RoundtripValidator.test.js | 160 ++++++++++ .../developer/shared/ViewportManager.js | 62 ++++ .../developer/shared/ViewportManager.test.js | 72 +++++ .../workarea/developer/style-lab/StyleLab.js | 209 +++++++++++++ .../developer/style-lab/StyleLab.test.js | 178 +++++++++++ .../style-lab/fixtures.manifest.json | 27 ++ .../workarea/menus/navbar/items/navbarHelp.js | 7 +- .../menus/navbar/items/navbarHelp.test.js | 8 +- src/index.ts | 3 + src/routes/developer.spec.ts | 88 ++++++ src/routes/developer.ts | 77 +++++ src/routes/pages.ts | 6 + src/utils/developer-tools.util.spec.ts | 88 ++++++ src/utils/developer-tools.util.ts | 76 +++++ test/e2e/playwright/pages/developer.page.ts | 122 ++++++++ .../playwright/specs/developer-tools.spec.ts | 146 +++++++++ .../idevices/checklist/completion.json | 12 + .../fixtures/idevices/rubric/basic-score.json | 11 + test/fixtures/idevices/text/rich.json | 4 + test/fixtures/style-lab/README.md | 22 ++ views/workarea/developer/ideviceLab.njk | 169 +++++++++++ views/workarea/developer/styleLab.njk | 135 ++++++++ views/workarea/menus/menuNavbar.njk | 14 +- 38 files changed, 3777 insertions(+), 3 deletions(-) create mode 100644 doc/development/developer-tools.md create mode 100644 public/app/workarea/developer/developer.css create mode 100644 public/app/workarea/developer/idevice-lab/IdeviceLab.js create mode 100644 public/app/workarea/developer/idevice-lab/IdeviceLab.test.js create mode 100644 public/app/workarea/developer/idevice-lab/samples.manifest.json create mode 100644 public/app/workarea/developer/shared/DeveloperStatusReporter.js create mode 100644 public/app/workarea/developer/shared/DeveloperStatusReporter.test.js create mode 100644 public/app/workarea/developer/shared/DeveloperUrlState.js create mode 100644 public/app/workarea/developer/shared/DeveloperUrlState.test.js create mode 100644 public/app/workarea/developer/shared/ExportPresetManager.js create mode 100644 public/app/workarea/developer/shared/ExportPresetManager.test.js create mode 100644 public/app/workarea/developer/shared/FixtureRegistry.js create mode 100644 public/app/workarea/developer/shared/FixtureRegistry.test.js create mode 100644 public/app/workarea/developer/shared/RoundtripValidator.js create mode 100644 public/app/workarea/developer/shared/RoundtripValidator.test.js create mode 100644 public/app/workarea/developer/shared/ViewportManager.js create mode 100644 public/app/workarea/developer/shared/ViewportManager.test.js create mode 100644 public/app/workarea/developer/style-lab/StyleLab.js create mode 100644 public/app/workarea/developer/style-lab/StyleLab.test.js create mode 100644 public/app/workarea/developer/style-lab/fixtures.manifest.json create mode 100644 src/routes/developer.spec.ts create mode 100644 src/routes/developer.ts create mode 100644 src/utils/developer-tools.util.spec.ts create mode 100644 src/utils/developer-tools.util.ts create mode 100644 test/e2e/playwright/pages/developer.page.ts create mode 100644 test/e2e/playwright/specs/developer-tools.spec.ts create mode 100644 test/fixtures/idevices/checklist/completion.json create mode 100644 test/fixtures/idevices/rubric/basic-score.json create mode 100644 test/fixtures/idevices/text/rich.json create mode 100644 test/fixtures/style-lab/README.md create mode 100644 views/workarea/developer/ideviceLab.njk create mode 100644 views/workarea/developer/styleLab.njk diff --git a/doc/development/developer-tools.md b/doc/development/developer-tools.md new file mode 100644 index 0000000000..6c086509dc --- /dev/null +++ b/doc/development/developer-tools.md @@ -0,0 +1,255 @@ +# Developer Tools — Style Lab, iDevice Lab and REST API + +> **Production safety**: every developer tool described here is gated by +> `APP_ENV=dev`. In production builds the menu is not rendered, the routes +> return `404 Not Found`, and the bundled JavaScript is unreferenced. Do +> not rely on the labs for production behavior — they are debugging +> surfaces designed for developers, automated Playwright tests and AI +> coding agents. + +## Overview + +The Developer menu adds three entries to the workarea navbar: + +| Entry | URL | Purpose | +|--------------|--------------------------------|---------------------------------------------------------| +| Style Lab | `/developer/style-lab` | Test eXeLearning themes against export targets/viewports | +| iDevice Lab | `/developer/idevice-lab` | Exercise an iDevice through its edit → save → export | +| REST API | `/api/v1/docs` | Swagger UI for the REST API (relocated from Help) | + +## Enabling the Developer menu + +Add `APP_ENV=dev` to your `.env` (or export it in the shell) before +starting the backend: + +```bash +APP_ENV=dev make up-local +``` + +Optional override for non-dev environments where the labs need to be +exposed (staging, demo deployments): + +```bash +DEV_TOOLS_ENABLED=1 +``` + +`DEV_TOOLS_ENABLED` accepts `1`, `true`, `yes`, `on` (case-insensitive). +Production deployments should leave both variables unset. + +## Architecture + +The labs are built on a small shared kernel at +[`public/app/workarea/developer/shared/`](../../public/app/workarea/developer/shared/): + +| Module | Responsibility | +|---------------------------|-------------------------------------------------------------| +| `DeveloperUrlState` | Serialize/deserialize lab state via URL parameters | +| `ViewportManager` | Apply desktop/tablet/mobile presets to a preview iframe | +| `FixtureRegistry` | Manifest-driven fixture lookup with strict ID sanitization | +| `ExportPresetManager` | Toggle bundles for export-option presets | +| `DeveloperStatusReporter` | Human/Playwright/AI-readable status + state JSON | +| `RoundtripValidator` | Run the save → load → save cycle and diff the snapshots | + +The shared kernel deliberately does not depend on iDevice or export +internals. The labs inject their own sandboxes/adapters at runtime so +the kernel stays portable. + +## Deterministic URL state + +Both labs honor URL parameters and write them back as you interact. +Examples — these URLs can be opened directly by Playwright, AI agents +or humans: + +``` +/developer/style-lab?fixture=leer-para-aprender&themeSource=base&theme=modern&export=scorm12&viewport=mobile + +/developer/idevice-lab?idevice=rubric&sample=basic-score&export=scorm12&viewport=desktop +``` + +Parameter rules: + +* IDs must match `^[A-Za-z0-9_-]{1,64}$`. Anything else is silently dropped. +* `viewport` ∈ {`desktop`, `tablet`, `mobile`}. +* `export` ∈ {`website`, `single-page`, `scorm12`, `scorm2004`, `ims`, `epub3`}. + Only targets that are actually wired are exposed in the UI; the rest fall back. +* `themeSource` ∈ {`base`, `site`, `user`}. + +Invalid values are replaced with safe defaults — they never reach the +filesystem. + +## Machine-readable state + +Each lab embeds a JSON ` +``` + +The `data-status` attribute on the status node mirrors the same value +so Playwright can wait without parsing JSON: + +```ts +await page.waitForFunction( + () => document.querySelector('[data-testid="developer-style-lab-status"]')?.getAttribute('data-status') === 'ready', +); +``` + +## Fixtures + +The Style Lab consumes a manifest at +[`public/app/workarea/developer/style-lab/fixtures.manifest.json`](../../public/app/workarea/developer/style-lab/fixtures.manifest.json). +The iDevice Lab consumes +[`public/app/workarea/developer/idevice-lab/samples.manifest.json`](../../public/app/workarea/developer/idevice-lab/samples.manifest.json). + +Reusable fixtures from +[`exelearning/exelearning-style-designer`](https://github.com/exelearning/exelearning-style-designer) +are welcome — the owner has explicitly authorized reuse. When adding a +Style-Designer-sourced fixture, set `"source": "exelearning-style-designer"` +in the manifest entry. + +Fixtures live under [`test/fixtures/style-lab/`](../../test/fixtures/style-lab/) +and `test/fixtures/idevices//`. + +## Theme sources + +The Style Lab understands the three official theme types: + +| Source | Where it lives | Reload-from-disk supported | +|--------|----------------------------------------|----------------------------| +| Base | `public/files/perm/themes/base/` | Yes | +| Site | `FILES_DIR/themes/site/` | Yes | +| User | Client IndexedDB + Yjs (browser-only) | No (reports unsupported) | + +User themes are never served from the backend; the lab respects the +existing client-side storage model. + +## Roundtrip validation + +The iDevice Lab's "Validate save/load roundtrip" button drives the +`RoundtripValidator`: + +1. Take initial data (the selected sample, or `{}` for a blank instance). +2. `loadData(initial)` → `save()` → snapshot A. +3. `loadData(snapshot A)` → `save()` → snapshot B. +4. Diff A vs B. Any lost, added, or mutated field becomes a structured + report. + +The result is mirrored to: + +* the **Roundtrip** tab (formatted JSON) +* `data-status` on `[data-testid="developer-idevice-lab-roundtrip-status"]` + (`passed` | `failed` | `error`) +* the `roundtrip` key inside the machine-readable state JSON + +## SCORM debug panel — limitations + +The SCORM debug tab is labeled clearly in the UI: + +> **Simulator only.** The SCORM panel is a developer simulator, not a +> full LMS runtime. Always verify SCORM behavior in a real LMS before +> release. + +Score/completion/success values are pulled from the iDevice's serialized +state via an adapter pattern. iDevices without an adapter show "Not +supported" rather than fake data. + +## Automation surface + +Stable `data-testid` attributes: + +```text +Menu + developer-menu + developer-menu-style-lab + developer-menu-idevice-lab + developer-menu-rest-api + +Style Lab + developer-style-lab-root + developer-style-lab-fixture-select + developer-style-lab-theme-source-select + developer-style-lab-theme-select + developer-style-lab-export-target-select + developer-style-lab-viewport-select + developer-style-lab-viewport-desktop / -tablet / -mobile + developer-style-lab-export-options-panel + developer-style-lab-preview-frame + developer-style-lab-reload-theme + developer-style-lab-status + developer-style-lab-error + developer-style-lab-state + +iDevice Lab + developer-idevice-lab-root + developer-idevice-lab-idevice-select + developer-idevice-lab-sample-select + developer-idevice-lab-theme-source-select + developer-idevice-lab-theme-select + developer-idevice-lab-export-target-select + developer-idevice-lab-viewport-select + developer-idevice-lab-tab-edition / -saved / -export / -roundtrip / -scorm + developer-idevice-lab-edition-view + developer-idevice-lab-saved-state + developer-idevice-lab-copy-state + developer-idevice-lab-export-view + developer-idevice-lab-run-roundtrip + developer-idevice-lab-roundtrip-result + developer-idevice-lab-roundtrip-status + developer-idevice-lab-scorm-panel + developer-idevice-lab-status + developer-idevice-lab-state +``` + +## AI agent recipes + +### Style visual check + +``` +1. GET /developer/style-lab?fixture=leer-para-aprender&themeSource=base&theme=modern&export=website&viewport=mobile +2. Wait for [data-testid="developer-style-lab-status"][data-status="ready"] +3. Screenshot [data-testid="developer-style-lab-preview-frame"] +4. Diff against the baseline image +``` + +### Style reload-from-disk check + +``` +1. Edit public/files/perm/themes/base//style.css +2. Open /developer/style-lab?themeSource=base&theme= +3. Click [data-testid="developer-style-lab-reload-theme"] +4. Wait for reloadToken in the state JSON to change +``` + +### iDevice roundtrip check + +``` +1. GET /developer/idevice-lab?idevice=rubric&sample=basic-score +2. Wait for [data-testid="developer-idevice-lab-status"][data-status="ready"] +3. Click [data-testid="developer-idevice-lab-run-roundtrip"] +4. Read roundtrip.status from the state JSON; expect "passed" +``` + +## Known limitations / follow-up work + +This is the initial scaffold. Items not yet wired to real production +infrastructure: + +* Style Lab preview iframe is currently a placeholder. Wiring it to the + existing `Html5Exporter.generateForPreview()` + Service Worker pipeline + (see [`public/preview-sw.js`](../../public/preview-sw.js)) is the next + step. +* iDevice Lab edition/export views render their containers but do not + yet mount real iDevice modules. The Sandbox interface in + `RoundtripValidator.run()` is the integration point. +* SCORM debug adapters: only the simulator scaffold exists; per-iDevice + adapters need to be added one at a time, starting with the rubric. +* `Reload style from disk` is a state token + status broadcast today; + the actual filesystem reload for Base/Site themes lives behind a + follow-up backend endpoint. + +When extending the labs, prefer reusing the existing preview/export +pipeline (`SharedExporters`, `DeveloperPreviewManager`) over building a +parallel renderer. diff --git a/playwright.config.ts b/playwright.config.ts index 59c16f456b..2b8146b4e7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -40,6 +40,7 @@ const dynamicServerEnv = { ADMIN_EMAIL: 'admin@exelearning.test', ADMIN_PASSWORD: 'AdminPass123!', ONLINE_THEMES_INSTALL: '1', // Enable theme import for E2E tests + APP_ENV: 'dev', // Exposes the Developer menu & dev-only routes for E2E coverage }; // Dynamic server config (used by chromium/firefox projects) diff --git a/public/app/workarea/developer/developer.css b/public/app/workarea/developer/developer.css new file mode 100644 index 0000000000..f90e21b278 --- /dev/null +++ b/public/app/workarea/developer/developer.css @@ -0,0 +1,224 @@ +/* eXeLearning Developer Tools — Style Lab & iDevice Lab + * Minimal layout. The labs are dev-only; visual polish is secondary to + * being navigable and stable for automation. + */ + +.developer-lab { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + max-width: 1400px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; + color: #1f2933; +} + +.developer-lab__header h1 { + font-size: 1.5rem; + margin: 0 0 0.25rem; +} + +.developer-lab__subtitle { + margin: 0 0 1rem; + color: #52606d; + font-size: 0.9rem; +} + +.developer-lab__nav { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.developer-lab__nav-link { + padding: 0.5rem 0.75rem; + text-decoration: none; + color: #374151; + border-bottom: 2px solid transparent; +} + +.developer-lab__nav-link.is-active { + color: #1f4cad; + border-bottom-color: #1f4cad; + font-weight: 600; +} + +.developer-lab__controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; + padding: 0.75rem; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 1rem; +} + +.developer-lab__control label { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #52606d; + margin-bottom: 0.25rem; +} + +.developer-lab__control select, +.developer-lab__control button { + width: 100%; + padding: 0.4rem 0.5rem; + border-radius: 4px; + border: 1px solid #cbd5e1; + background: white; + font-size: 0.9rem; +} + +.developer-lab__viewport-buttons { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.developer-lab__viewport-buttons button { + flex: 1; + font-size: 0.75rem; + padding: 0.25rem; +} + +.developer-lab__options { + margin-bottom: 1rem; +} + +.developer-lab__options summary { + cursor: pointer; + padding: 0.5rem 0; + font-weight: 600; +} + +.developer-lab__options label { + display: inline-flex; + gap: 0.25rem; + margin-right: 1rem; + margin-bottom: 0.25rem; +} + +.developer-lab__preview-wrap { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 1rem; + overflow: auto; + min-height: 500px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.developer-lab__preview-frame { + border: 1px solid #cbd5e1; + background: white; + transition: width 0.15s ease, height 0.15s ease; + max-width: 100%; +} + +.developer-lab__status-bar { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; + padding: 0.5rem 0.75rem; + background: #f3f4f6; + border-radius: 4px; + font-size: 0.85rem; +} + +.developer-lab__status[data-status="error"] { + color: #b91c1c; + font-weight: 600; +} + +.developer-lab__status[data-status="ready"] { + color: #047857; +} + +.developer-lab__error { + color: #b91c1c; + font-size: 0.85rem; +} + +.developer-lab__tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid #e5e7eb; + margin-bottom: 1rem; +} + +.developer-lab__tabs button { + border: none; + background: transparent; + padding: 0.5rem 0.75rem; + cursor: pointer; + color: #374151; + border-bottom: 2px solid transparent; +} + +.developer-lab__tabs button[aria-selected="true"] { + color: #1f4cad; + border-bottom-color: #1f4cad; + font-weight: 600; +} + +.developer-lab__panel { + background: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 1rem; + min-height: 400px; +} + +.developer-lab__panel pre { + background: #0f172a; + color: #e2e8f0; + padding: 1rem; + border-radius: 4px; + overflow: auto; + font-size: 0.8rem; +} + +.developer-lab__warning { + background: #fef3c7; + border: 1px solid #f59e0b; + color: #78350f; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; +} + +.developer-lab__panel[role="tabpanel"] iframe { + width: 100%; + height: 600px; + border: 1px solid #cbd5e1; +} + +#developer-idevice-lab-roundtrip-status { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 999px; + background: #e5e7eb; + font-weight: 600; + margin-bottom: 0.5rem; +} + +#developer-idevice-lab-roundtrip-status[data-status="passed"] { + background: #d1fae5; + color: #065f46; +} + +#developer-idevice-lab-roundtrip-status[data-status="failed"] { + background: #fee2e2; + color: #991b1b; +} + +#developer-idevice-lab-roundtrip-status[data-status="error"] { + background: #fef3c7; + color: #78350f; +} diff --git a/public/app/workarea/developer/idevice-lab/IdeviceLab.js b/public/app/workarea/developer/idevice-lab/IdeviceLab.js new file mode 100644 index 0000000000..65502002a8 --- /dev/null +++ b/public/app/workarea/developer/idevice-lab/IdeviceLab.js @@ -0,0 +1,287 @@ +/** + * IdeviceLab + * + * Top-level controller for the Developer > iDevice Lab page. Coordinates: + * + * - iDevice/sample/theme/export/viewport selection (deterministic URL state) + * - Tabbed edition / saved-state / export / roundtrip / SCORM panels + * - The save/load roundtrip validator + * + * The actual iDevice registry is read from `window.eXeLearning.app.idevices` + * at runtime so the lab uses the same source as the workarea. When the + * registry is missing (e.g. during isolated unit tests) the lab falls back + * to the samples manifest so it still has something selectable. + */ + +import { + parseIdeviceLabState, + serializeIdeviceLabState, +} from '../shared/DeveloperUrlState.js'; +import { ViewportManager } from '../shared/ViewportManager.js'; +import { DeveloperStatusReporter, STATUS } from '../shared/DeveloperStatusReporter.js'; +import { RoundtripValidator, ROUNDTRIP_STATUS } from '../shared/RoundtripValidator.js'; + +import samplesManifest from './samples.manifest.json'; + +export class IdeviceLab { + constructor({ root, window: win = window, registry = null } = {}) { + this.root = root; + this.window = win; + this.registry = registry ?? readRegistryFromWindow(win); + this.samples = samplesManifest; + this.iframe = root?.querySelector('#developer-idevice-lab-export-frame') ?? null; + this.viewport = new ViewportManager(this.iframe); + this.reporter = new DeveloperStatusReporter({ + statusEl: root?.querySelector('[data-testid="developer-idevice-lab-status"]'), + stateEl: root?.querySelector('[data-testid="developer-idevice-lab-state"]'), + }); + this.state = parseIdeviceLabState(win.location?.search ?? ''); + } + + init() { + try { + this.populateIdeviceSelect(); + this.populateSampleSelect(); + this.bindControls(); + this.applyStateToControls(); + this.applyState(); + this.reporter.setStatus(STATUS.READY, 'Ready'); + } catch (err) { + this.reporter.setError(err); + } + return this; + } + + listIdevices() { + if (this.registry && Array.isArray(this.registry)) return this.registry; + // Fallback: derive from the samples manifest so the lab always has + // something selectable in tests/CI. + return this.samples.map(entry => ({ id: entry.idevice, label: entry.idevice })); + } + + listSamplesFor(idevice) { + const entry = this.samples.find(s => s.idevice === idevice); + return entry?.samples ?? []; + } + + populateIdeviceSelect() { + const select = this.root?.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + if (!select) return; + select.innerHTML = ''; + for (const entry of this.listIdevices()) { + const opt = this.window.document.createElement('option'); + opt.value = entry.id; + opt.textContent = entry.label ?? entry.id; + select.appendChild(opt); + } + if (!this.state.idevice && select.options.length > 0) { + this.state.idevice = select.options[0].value; + } + if (this.state.idevice) select.value = this.state.idevice; + } + + populateSampleSelect() { + const select = this.root?.querySelector('[data-testid="developer-idevice-lab-sample-select"]'); + if (!select || !this.state.idevice) return; + select.innerHTML = ''; + const samples = this.listSamplesFor(this.state.idevice); + if (samples.length === 0) { + const opt = this.window.document.createElement('option'); + opt.value = ''; + opt.textContent = '(blank instance)'; + select.appendChild(opt); + this.state.sample = null; + return; + } + for (const sample of samples) { + const opt = this.window.document.createElement('option'); + opt.value = sample.id; + opt.textContent = sample.label; + select.appendChild(opt); + } + if (!this.state.sample) this.state.sample = samples[0].id; + select.value = this.state.sample; + } + + bindControls() { + const r = this.root; + if (!r) return; + + const onChange = (selector, key, after) => { + const el = r.querySelector(selector); + if (!el) return; + el.addEventListener('change', () => { + this.state[key] = el.value || null; + if (after) after(); + this.syncUrl(); + this.applyState(); + }); + }; + + onChange('[data-testid="developer-idevice-lab-idevice-select"]', 'idevice', () => { + this.state.sample = null; + this.populateSampleSelect(); + }); + onChange('[data-testid="developer-idevice-lab-sample-select"]', 'sample'); + onChange('[data-testid="developer-idevice-lab-theme-source-select"]', 'themeSource'); + onChange('[data-testid="developer-idevice-lab-theme-select"]', 'theme'); + onChange('[data-testid="developer-idevice-lab-export-target-select"]', 'export'); + onChange('[data-testid="developer-idevice-lab-viewport-select"]', 'viewport'); + + // Tab switching + r.querySelectorAll('[role="tab"][data-tab]').forEach(tab => { + tab.addEventListener('click', () => this.activateTab(tab.getAttribute('data-tab'))); + }); + + const roundtripBtn = r.querySelector('[data-testid="developer-idevice-lab-run-roundtrip"]'); + if (roundtripBtn) roundtripBtn.addEventListener('click', () => this.runRoundtrip()); + + const copyBtn = r.querySelector('[data-testid="developer-idevice-lab-copy-state"]'); + if (copyBtn) copyBtn.addEventListener('click', () => this.copySavedState()); + } + + activateTab(name) { + const r = this.root; + if (!r) return; + r.querySelectorAll('[role="tab"][data-tab]').forEach(tab => { + tab.setAttribute('aria-selected', String(tab.getAttribute('data-tab') === name)); + }); + r.querySelectorAll('[data-panel]').forEach(panel => { + const active = panel.getAttribute('data-panel') === name; + if (active) { + panel.removeAttribute('hidden'); + panel.classList.add('is-active'); + } else { + panel.setAttribute('hidden', ''); + panel.classList.remove('is-active'); + } + }); + this.reporter.setState({ activeTab: name }); + } + + applyStateToControls() { + const r = this.root; + if (!r) return; + const setValue = (selector, value) => { + const el = r.querySelector(selector); + if (el && value != null) el.value = value; + }; + setValue('[data-testid="developer-idevice-lab-idevice-select"]', this.state.idevice); + setValue('[data-testid="developer-idevice-lab-sample-select"]', this.state.sample); + setValue('[data-testid="developer-idevice-lab-theme-source-select"]', this.state.themeSource); + setValue('[data-testid="developer-idevice-lab-theme-select"]', this.state.theme); + setValue('[data-testid="developer-idevice-lab-export-target-select"]', this.state.export); + setValue('[data-testid="developer-idevice-lab-viewport-select"]', this.state.viewport); + } + + applyState() { + this.viewport.apply(this.state.viewport); + this.reporter.replaceState({ + idevice: this.state.idevice, + sample: this.state.sample, + themeSource: this.state.themeSource, + theme: this.state.theme, + export: this.state.export, + viewport: this.state.viewport, + status: this.reporter.statusValue, + roundtrip: { status: ROUNDTRIP_STATUS.IDLE }, + }); + } + + syncUrl() { + const params = serializeIdeviceLabState(this.state); + const search = params.toString(); + const next = (this.window.location?.pathname ?? '') + (search ? `?${search}` : ''); + if (this.window.history?.replaceState) { + this.window.history.replaceState({}, '', next); + } + } + + /** + * Run the save/load roundtrip validator against the current iDevice's + * sample data. The validator uses an injectable sandbox so the lab does + * not need iDevice-specific knowledge; consumers can override + * `createSandboxFor(idevice)` to supply a real sandbox. + */ + async runRoundtrip() { + const r = this.root; + const statusEl = r?.querySelector('[data-testid="developer-idevice-lab-roundtrip-status"]'); + const outputEl = r?.querySelector('#developer-idevice-lab-roundtrip-output'); + if (statusEl) statusEl.setAttribute('data-status', ROUNDTRIP_STATUS.RUNNING); + this.reporter.setState({ roundtrip: { status: ROUNDTRIP_STATUS.RUNNING } }); + + const sandbox = this.createSandboxFor(this.state.idevice); + const validator = new RoundtripValidator({ sandbox }); + const initialData = this.loadSampleData(this.state.idevice, this.state.sample); + const result = await validator.run(initialData); + + if (statusEl) statusEl.setAttribute('data-status', result.status); + if (outputEl) outputEl.textContent = JSON.stringify(result, null, 2); + this.reporter.setState({ + roundtrip: { + status: result.status, + lostFields: result.lostFields ?? [], + addedFields: result.addedFields ?? [], + mutatedFields: result.mutatedFields ?? [], + error: result.error, + }, + }); + return result; + } + + /** + * Default sandbox: pass-through. Real implementations should replace this + * with one that instantiates the iDevice's edition module, calls + * loadData/save, and returns the serialized state. + */ + createSandboxFor(_idevice) { + return { + loadAndSave: async data => (data === undefined || data === null ? null : JSON.parse(JSON.stringify(data))), + }; + } + + /** + * Default sample loader: return a synthetic shape with the sample id so + * the validator has something to chew on. Real implementations load + * the JSON fixture pointed to by the manifest. + */ + loadSampleData(idevice, sample) { + if (!idevice) return {}; + if (!sample) return { idevice, _emptySample: true }; + return { idevice, sample, content: 'sample-content' }; + } + + copySavedState() { + const text = this.root?.querySelector('#developer-idevice-lab-saved-state-content')?.textContent ?? ''; + if (this.window.navigator?.clipboard?.writeText) { + return this.window.navigator.clipboard.writeText(text).catch(() => undefined); + } + return Promise.resolve(); + } +} + +function readRegistryFromWindow(win) { + try { + const idevices = win?.eXeLearning?.app?.idevices; + if (!idevices) return null; + if (Array.isArray(idevices)) return idevices; + if (typeof idevices.list === 'function') return idevices.list(); + return null; + } catch { + return null; + } +} + +export default IdeviceLab; + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const start = () => { + const root = document.querySelector('[data-testid="developer-idevice-lab-root"]'); + if (root) new IdeviceLab({ root, window }).init(); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start, { once: true }); + } else { + start(); + } +} diff --git a/public/app/workarea/developer/idevice-lab/IdeviceLab.test.js b/public/app/workarea/developer/idevice-lab/IdeviceLab.test.js new file mode 100644 index 0000000000..efe8ab7e28 --- /dev/null +++ b/public/app/workarea/developer/idevice-lab/IdeviceLab.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { IdeviceLab } from './IdeviceLab.js'; + +function buildDom() { + document.body.innerHTML = ` +
+ + + + + + + + + + +
+ + + + + +
{"hello":"world"}
+
+ +
+ `; + return document.querySelector('[data-testid="developer-idevice-lab-root"]'); +} + +function makeWindow({ search = '' } = {}) { + const calls = []; + return { + location: { pathname: '/developer/idevice-lab', search }, + history: { replaceState(_s, _t, url) { calls.push(url); } }, + document, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + __calls: calls, + }; +} + +describe('IdeviceLab', () => { + let root; + let win; + + beforeEach(() => { + root = buildDom(); + win = makeWindow(); + }); + + it('populates the iDevice select from the registry when supplied', () => { + const registry = [ + { id: 'rubric', label: 'Rubric' }, + { id: 'checklist', label: 'Checklist' }, + ]; + new IdeviceLab({ root, window: win, registry }).init(); + const select = root.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + expect(Array.from(select.options).map(o => o.value)).toEqual(['rubric', 'checklist']); + }); + + it('falls back to the samples manifest when no registry is available', () => { + new IdeviceLab({ root, window: win }).init(); + const select = root.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + const ids = Array.from(select.options).map(o => o.value); + expect(ids.length).toBeGreaterThan(0); + expect(ids).toContain('rubric'); + }); + + it('reports ready status after init', () => { + new IdeviceLab({ root, window: win }).init(); + const status = root.querySelector('[data-testid="developer-idevice-lab-status"]'); + expect(status.getAttribute('data-status')).toBe('ready'); + }); + + it('writes machine-readable state', () => { + new IdeviceLab({ root, window: win }).init(); + const stateEl = root.querySelector('[data-testid="developer-idevice-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.export).toBe('website'); + expect(parsed.viewport).toBe('desktop'); + expect(parsed.status).toBe('ready'); + expect(parsed.idevice).toBeDefined(); + }); + + it('restores state from URL parameters', () => { + win = makeWindow({ search: '?idevice=rubric&sample=basic-score&export=scorm12&viewport=mobile' }); + new IdeviceLab({ root, window: win }).init(); + const stateEl = root.querySelector('[data-testid="developer-idevice-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.idevice).toBe('rubric'); + expect(parsed.sample).toBe('basic-score'); + expect(parsed.export).toBe('scorm12'); + expect(parsed.viewport).toBe('mobile'); + }); + + it('switches tabs when a tab button is clicked', () => { + new IdeviceLab({ root, window: win }).init(); + const tab = root.querySelector('[data-testid="developer-idevice-lab-tab-saved"]'); + tab.click(); + expect(tab.getAttribute('aria-selected')).toBe('true'); + const editionTab = root.querySelector('[data-testid="developer-idevice-lab-tab-edition"]'); + expect(editionTab.getAttribute('aria-selected')).toBe('false'); + const editionPanel = root.querySelector('[data-panel="edition"]'); + expect(editionPanel.hasAttribute('hidden')).toBe(true); + const savedPanel = root.querySelector('[data-panel="saved"]'); + expect(savedPanel.hasAttribute('hidden')).toBe(false); + }); + + it('runs the roundtrip validator and reports status', async () => { + const lab = new IdeviceLab({ root, window: win }).init(); + const result = await lab.runRoundtrip(); + expect(result.status).toBe('passed'); + const statusEl = root.querySelector('[data-testid="developer-idevice-lab-roundtrip-status"]'); + expect(statusEl.getAttribute('data-status')).toBe('passed'); + const stateEl = root.querySelector('[data-testid="developer-idevice-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.roundtrip.status).toBe('passed'); + }); + + it('reports failed roundtrip status when sandbox drops fields', async () => { + const lab = new IdeviceLab({ root, window: win }).init(); + let call = 0; + lab.createSandboxFor = () => ({ + loadAndSave: data => { + call += 1; + if (call === 1) return { ...data }; + const clone = { ...data }; + delete clone.sample; + return clone; + }, + }); + const result = await lab.runRoundtrip(); + expect(result.status).toBe('failed'); + expect(result.lostFields).toContain('sample'); + }); + + it('falls back to a blank instance when no sample exists for the idevice', () => { + const registry = [{ id: 'unknown-idevice', label: 'unknown' }]; + new IdeviceLab({ root, window: win, registry }).init(); + const sampleSelect = root.querySelector('[data-testid="developer-idevice-lab-sample-select"]'); + expect(Array.from(sampleSelect.options).map(o => o.textContent)).toContain('(blank instance)'); + }); + + it('updates URL when an iDevice is selected', () => { + const registry = [ + { id: 'rubric', label: 'Rubric' }, + { id: 'checklist', label: 'Checklist' }, + ]; + new IdeviceLab({ root, window: win, registry }).init(); + const select = root.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + select.value = 'checklist'; + select.dispatchEvent(new Event('change', { bubbles: true })); + expect(win.__calls[win.__calls.length - 1]).toContain('idevice=checklist'); + }); + + it('reads registry from window.eXeLearning.app.idevices when available', () => { + const winWithRegistry = makeWindow(); + winWithRegistry.eXeLearning = { app: { idevices: [{ id: 'rubric', label: 'R' }] } }; + new IdeviceLab({ root, window: winWithRegistry }).init(); + const select = root.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + expect(Array.from(select.options).map(o => o.value)).toEqual(['rubric']); + }); + + it('reads registry from window.eXeLearning.app.idevices.list() when present', () => { + const winWithRegistry = makeWindow(); + winWithRegistry.eXeLearning = { + app: { idevices: { list: () => [{ id: 'text', label: 'Text' }] } }, + }; + new IdeviceLab({ root, window: winWithRegistry }).init(); + const select = root.querySelector('[data-testid="developer-idevice-lab-idevice-select"]'); + expect(Array.from(select.options).map(o => o.value)).toEqual(['text']); + }); +}); diff --git a/public/app/workarea/developer/idevice-lab/samples.manifest.json b/public/app/workarea/developer/idevice-lab/samples.manifest.json new file mode 100644 index 0000000000..d3eda645d5 --- /dev/null +++ b/public/app/workarea/developer/idevice-lab/samples.manifest.json @@ -0,0 +1,32 @@ +[ + { + "idevice": "rubric", + "samples": [ + { + "id": "basic-score", + "label": "Basic scored rubric", + "path": "test/fixtures/idevices/rubric/basic-score.json" + } + ] + }, + { + "idevice": "checklist", + "samples": [ + { + "id": "completion-checklist", + "label": "Completion checklist", + "path": "test/fixtures/idevices/checklist/completion.json" + } + ] + }, + { + "idevice": "text", + "samples": [ + { + "id": "rich-text", + "label": "Rich text block", + "path": "test/fixtures/idevices/text/rich.json" + } + ] + } +] diff --git a/public/app/workarea/developer/shared/DeveloperStatusReporter.js b/public/app/workarea/developer/shared/DeveloperStatusReporter.js new file mode 100644 index 0000000000..8e8e5525fd --- /dev/null +++ b/public/app/workarea/developer/shared/DeveloperStatusReporter.js @@ -0,0 +1,105 @@ +/** + * DeveloperStatusReporter + * + * Centralizes how developer-tool labs surface their state to: + * + * 1. The user — via a status DOM node + * 2. Playwright — via `data-status` attributes + * 3. AI agents — via a JSON state element they can read with one + * `evaluate` call + * + * The reporter is deliberately small: any lab that wants "machine readable + * status" can wrap a status node + a state node and forget about the wiring. + */ + +export const STATUS = Object.freeze({ + INITIALIZING: 'initializing', + LOADING: 'loading', + READY: 'ready', + ERROR: 'error', +}); + +export class DeveloperStatusReporter { + constructor({ statusEl = null, errorEl = null, stateEl = null } = {}) { + this.statusEl = statusEl; + this.errorEl = errorEl; + this.stateEl = stateEl; + this.state = {}; + this.statusValue = STATUS.INITIALIZING; + } + + /** + * Update the machine-readable state payload (merged shallowly). + * Also re-renders the JSON state element if attached. + */ + setState(patch) { + if (patch && typeof patch === 'object') { + this.state = { ...this.state, ...patch }; + } + this.#renderState(); + return this.state; + } + + /** + * Replace the entire state payload. + */ + replaceState(next) { + this.state = next && typeof next === 'object' ? { ...next } : {}; + this.#renderState(); + return this.state; + } + + /** + * Set the human-visible status text and the data-status attribute. + */ + setStatus(value, message) { + this.statusValue = value; + if (this.statusEl) { + this.statusEl.setAttribute('data-status', value); + if (typeof message === 'string') this.statusEl.textContent = message; + } + this.setState({ status: value }); + } + + /** + * Display an error message and switch status to "error". + */ + setError(message) { + const text = typeof message === 'string' ? message : (message?.message ?? 'Unknown error'); + this.setStatus(STATUS.ERROR, text); + if (this.errorEl) { + this.errorEl.hidden = false; + this.errorEl.textContent = text; + } + this.setState({ error: text }); + } + + /** + * Clear an error and switch status back to "ready". + */ + clearError() { + if (this.errorEl) { + this.errorEl.hidden = true; + this.errorEl.textContent = ''; + } + const next = { ...this.state }; + delete next.error; + this.replaceState(next); + } + + getState() { + return { ...this.state }; + } + + #renderState() { + if (this.stateEl) { + try { + this.stateEl.textContent = JSON.stringify(this.state); + } catch { + this.stateEl.textContent = '{}'; + } + } + } +} + +export default DeveloperStatusReporter; diff --git a/public/app/workarea/developer/shared/DeveloperStatusReporter.test.js b/public/app/workarea/developer/shared/DeveloperStatusReporter.test.js new file mode 100644 index 0000000000..55e5cbcef0 --- /dev/null +++ b/public/app/workarea/developer/shared/DeveloperStatusReporter.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DeveloperStatusReporter, STATUS } from './DeveloperStatusReporter.js'; + +function makeEl() { + const attrs = {}; + return { + hidden: false, + textContent: '', + getAttribute: k => attrs[k], + setAttribute: (k, v) => { + attrs[k] = v; + }, + }; +} + +describe('DeveloperStatusReporter', () => { + let statusEl; + let errorEl; + let stateEl; + + beforeEach(() => { + statusEl = makeEl(); + errorEl = makeEl(); + errorEl.hidden = true; + stateEl = makeEl(); + }); + + it('starts in the initializing status', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + expect(r.statusValue).toBe(STATUS.INITIALIZING); + }); + + it('updates status text and data-status attribute', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setStatus(STATUS.READY, 'All set'); + expect(statusEl.textContent).toBe('All set'); + expect(statusEl.getAttribute('data-status')).toBe('ready'); + }); + + it('writes JSON to the state element', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setState({ fixture: 'demo', viewport: 'mobile' }); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.fixture).toBe('demo'); + expect(parsed.viewport).toBe('mobile'); + }); + + it('merges state shallowly', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setState({ a: 1, b: 2 }); + r.setState({ b: 3, c: 4 }); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it('replaces state entirely with replaceState()', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setState({ a: 1, b: 2 }); + r.replaceState({ c: 3 }); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed).toEqual({ c: 3 }); + }); + + it('records status in state', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setStatus(STATUS.READY, 'ok'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.status).toBe('ready'); + }); + + it('reports errors and shows the error element', () => { + const r = new DeveloperStatusReporter({ statusEl, errorEl, stateEl }); + r.setError('boom'); + expect(errorEl.hidden).toBe(false); + expect(errorEl.textContent).toBe('boom'); + expect(statusEl.getAttribute('data-status')).toBe('error'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.error).toBe('boom'); + }); + + it('accepts Error instances', () => { + const r = new DeveloperStatusReporter({ statusEl, errorEl, stateEl }); + r.setError(new Error('detailed message')); + expect(errorEl.textContent).toBe('detailed message'); + }); + + it('falls back to "Unknown error" when nothing useful is passed', () => { + const r = new DeveloperStatusReporter({ statusEl, errorEl, stateEl }); + r.setError({}); + expect(errorEl.textContent).toBe('Unknown error'); + }); + + it('clears errors via clearError()', () => { + const r = new DeveloperStatusReporter({ statusEl, errorEl, stateEl }); + r.setError('bad'); + r.clearError(); + expect(errorEl.hidden).toBe(true); + expect(errorEl.textContent).toBe(''); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.error).toBeUndefined(); + }); + + it('survives missing DOM elements', () => { + const r = new DeveloperStatusReporter({}); + expect(() => r.setStatus('ready', 'ok')).not.toThrow(); + expect(() => r.setError('boom')).not.toThrow(); + expect(() => r.setState({ a: 1 })).not.toThrow(); + }); + + it('returns a defensive copy of the state', () => { + const r = new DeveloperStatusReporter({ statusEl, stateEl }); + r.setState({ a: 1 }); + const snapshot = r.getState(); + snapshot.a = 99; + expect(r.getState().a).toBe(1); + }); +}); diff --git a/public/app/workarea/developer/shared/DeveloperUrlState.js b/public/app/workarea/developer/shared/DeveloperUrlState.js new file mode 100644 index 0000000000..c34fabfcb6 --- /dev/null +++ b/public/app/workarea/developer/shared/DeveloperUrlState.js @@ -0,0 +1,206 @@ +/** + * DeveloperUrlState + * + * Serializes/deserializes Developer-tools lab state to/from URL query + * parameters so each scenario is shareable, bookmarkable, and reachable + * by Playwright/AI agents through a deterministic URL. + * + * Parameters accepted: + * + * Style Lab → fixture, themeSource, theme, export, viewport, preset, + * search, pageCounter, navigation, icons, collapsible + * iDevice Lab → idevice, sample, themeSource, theme, export, viewport + * + * The parser is intentionally tolerant: unknown keys are ignored and invalid + * values fall back to the supplied defaults. The serializer only emits + * parameters whose value differs from the default so URLs stay short. + */ + +export const VIEWPORTS = Object.freeze(['desktop', 'tablet', 'mobile']); +export const EXPORT_TARGETS = Object.freeze(['website', 'single-page', 'scorm12', 'scorm2004', 'ims', 'epub3']); +export const THEME_SOURCES = Object.freeze(['base', 'site', 'user']); + +const BOOLEAN_FLAGS = Object.freeze(['search', 'pageCounter', 'navigation', 'icons', 'collapsible']); +const STRING_KEYS_STYLE = Object.freeze(['fixture', 'theme', 'preset']); +const STRING_KEYS_IDEVICE = Object.freeze(['idevice', 'sample', 'theme']); +// Identifier sanitization: alphanumerics, dash, underscore. Length-capped. +const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +function sanitizeId(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return SAFE_ID_RE.test(trimmed) ? trimmed : null; +} + +function sanitizeEnum(value, allowed) { + return typeof value === 'string' && allowed.includes(value) ? value : null; +} + +function sanitizeBool(value) { + if (value === true || value === false) return value; + if (typeof value !== 'string') return null; + const v = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(v)) return true; + if (['0', 'false', 'no', 'off'].includes(v)) return false; + return null; +} + +/** + * Parse Style Lab state from a URLSearchParams-compatible object or query string. + */ +export function parseStyleLabState(input, defaults = {}) { + const params = toSearchParams(input); + const state = { + fixture: defaults.fixture ?? null, + themeSource: defaults.themeSource ?? 'base', + theme: defaults.theme ?? null, + export: defaults.export ?? 'website', + viewport: defaults.viewport ?? 'desktop', + preset: defaults.preset ?? null, + options: { + search: defaults.options?.search ?? false, + pageCounter: defaults.options?.pageCounter ?? false, + navigation: defaults.options?.navigation ?? true, + icons: defaults.options?.icons ?? true, + collapsible: defaults.options?.collapsible ?? false, + }, + }; + + for (const key of STRING_KEYS_STYLE) { + if (params.has(key)) { + const v = sanitizeId(params.get(key)); + if (v !== null) state[key] = v; + } + } + if (params.has('themeSource')) { + const v = sanitizeEnum(params.get('themeSource'), THEME_SOURCES); + if (v !== null) state.themeSource = v; + } + if (params.has('export')) { + const v = sanitizeEnum(params.get('export'), EXPORT_TARGETS); + if (v !== null) state.export = v; + } + if (params.has('viewport')) { + const v = sanitizeEnum(params.get('viewport'), VIEWPORTS); + if (v !== null) state.viewport = v; + } + for (const flag of BOOLEAN_FLAGS) { + if (params.has(flag)) { + const v = sanitizeBool(params.get(flag)); + if (v !== null) state.options[flag] = v; + } + } + return state; +} + +/** + * Serialize Style Lab state back into URLSearchParams. + * Only emits parameters whose value differs from the default. + */ +export function serializeStyleLabState(state, defaults = {}) { + const baseDefaults = { + themeSource: 'base', + export: 'website', + viewport: 'desktop', + options: { search: false, pageCounter: false, navigation: true, icons: true, collapsible: false }, + ...defaults, + }; + const params = new URLSearchParams(); + for (const key of STRING_KEYS_STYLE) { + const v = state[key]; + if (v && sanitizeId(v)) params.set(key, v); + } + if (state.themeSource && state.themeSource !== baseDefaults.themeSource) { + params.set('themeSource', state.themeSource); + } + if (state.export && state.export !== baseDefaults.export) { + params.set('export', state.export); + } + if (state.viewport && state.viewport !== baseDefaults.viewport) { + params.set('viewport', state.viewport); + } + const opts = state.options ?? {}; + for (const flag of BOOLEAN_FLAGS) { + if (typeof opts[flag] !== 'undefined' && opts[flag] !== baseDefaults.options[flag]) { + params.set(flag, opts[flag] ? '1' : '0'); + } + } + return params; +} + +/** + * Parse iDevice Lab state from a URLSearchParams-compatible object or query string. + */ +export function parseIdeviceLabState(input, defaults = {}) { + const params = toSearchParams(input); + const state = { + idevice: defaults.idevice ?? null, + sample: defaults.sample ?? null, + themeSource: defaults.themeSource ?? 'base', + theme: defaults.theme ?? null, + export: defaults.export ?? 'website', + viewport: defaults.viewport ?? 'desktop', + }; + for (const key of STRING_KEYS_IDEVICE) { + if (params.has(key)) { + const v = sanitizeId(params.get(key)); + if (v !== null) state[key] = v; + } + } + if (params.has('themeSource')) { + const v = sanitizeEnum(params.get('themeSource'), THEME_SOURCES); + if (v !== null) state.themeSource = v; + } + if (params.has('export')) { + const v = sanitizeEnum(params.get('export'), EXPORT_TARGETS); + if (v !== null) state.export = v; + } + if (params.has('viewport')) { + const v = sanitizeEnum(params.get('viewport'), VIEWPORTS); + if (v !== null) state.viewport = v; + } + return state; +} + +/** + * Serialize iDevice Lab state back into URLSearchParams. + */ +export function serializeIdeviceLabState(state, defaults = {}) { + const baseDefaults = { + themeSource: 'base', + export: 'website', + viewport: 'desktop', + ...defaults, + }; + const params = new URLSearchParams(); + for (const key of STRING_KEYS_IDEVICE) { + const v = state[key]; + if (v && sanitizeId(v)) params.set(key, v); + } + if (state.idevice && sanitizeId(state.idevice)) params.set('idevice', state.idevice); + if (state.themeSource && state.themeSource !== baseDefaults.themeSource) { + params.set('themeSource', state.themeSource); + } + if (state.export && state.export !== baseDefaults.export) { + params.set('export', state.export); + } + if (state.viewport && state.viewport !== baseDefaults.viewport) { + params.set('viewport', state.viewport); + } + return params; +} + +function toSearchParams(input) { + if (input instanceof URLSearchParams) return input; + if (typeof input === 'string') { + return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input); + } + if (input && typeof input === 'object') { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(input)) { + if (v !== undefined && v !== null) p.set(k, String(v)); + } + return p; + } + return new URLSearchParams(); +} diff --git a/public/app/workarea/developer/shared/DeveloperUrlState.test.js b/public/app/workarea/developer/shared/DeveloperUrlState.test.js new file mode 100644 index 0000000000..f04b13e079 --- /dev/null +++ b/public/app/workarea/developer/shared/DeveloperUrlState.test.js @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { + parseStyleLabState, + serializeStyleLabState, + parseIdeviceLabState, + serializeIdeviceLabState, + VIEWPORTS, + EXPORT_TARGETS, + THEME_SOURCES, +} from './DeveloperUrlState.js'; + +describe('DeveloperUrlState - Style Lab', () => { + it('returns safe defaults when the URL is empty', () => { + const state = parseStyleLabState(''); + expect(state.fixture).toBeNull(); + expect(state.themeSource).toBe('base'); + expect(state.export).toBe('website'); + expect(state.viewport).toBe('desktop'); + expect(state.options.navigation).toBe(true); + expect(state.options.icons).toBe(true); + expect(state.options.search).toBe(false); + }); + + it('parses a full URL', () => { + const state = parseStyleLabState( + 'fixture=leer-para-aprender&themeSource=base&theme=modern&export=scorm12&viewport=mobile&preset=all&search=1&pageCounter=1&navigation=0', + ); + expect(state.fixture).toBe('leer-para-aprender'); + expect(state.themeSource).toBe('base'); + expect(state.theme).toBe('modern'); + expect(state.export).toBe('scorm12'); + expect(state.viewport).toBe('mobile'); + expect(state.preset).toBe('all'); + expect(state.options.search).toBe(true); + expect(state.options.pageCounter).toBe(true); + expect(state.options.navigation).toBe(false); + }); + + it('accepts URLSearchParams as input', () => { + const params = new URLSearchParams({ viewport: 'tablet', export: 'single-page' }); + const state = parseStyleLabState(params); + expect(state.viewport).toBe('tablet'); + expect(state.export).toBe('single-page'); + }); + + it('accepts a plain object as input', () => { + const state = parseStyleLabState({ viewport: 'mobile' }); + expect(state.viewport).toBe('mobile'); + }); + + it('falls back when viewport is invalid', () => { + const state = parseStyleLabState('viewport=cinema'); + expect(state.viewport).toBe('desktop'); + }); + + it('falls back when export target is invalid', () => { + const state = parseStyleLabState('export=mobile'); + // 'mobile' is a viewport, not an export target — must reject + expect(state.export).toBe('website'); + }); + + it('falls back when theme source is invalid', () => { + const state = parseStyleLabState('themeSource=hacker'); + expect(state.themeSource).toBe('base'); + }); + + it('rejects path-traversal in fixture id', () => { + const state = parseStyleLabState('fixture=../../etc/passwd'); + expect(state.fixture).toBeNull(); + }); + + it('rejects fixture ids containing slashes or dots', () => { + expect(parseStyleLabState('fixture=foo/bar').fixture).toBeNull(); + expect(parseStyleLabState('fixture=foo.bar').fixture).toBeNull(); + expect(parseStyleLabState('fixture=foo bar').fixture).toBeNull(); + }); + + it('accepts fixture ids using safe characters', () => { + expect(parseStyleLabState('fixture=leer-para-aprender').fixture).toBe('leer-para-aprender'); + expect(parseStyleLabState('fixture=basic_content').fixture).toBe('basic_content'); + expect(parseStyleLabState('fixture=Style123').fixture).toBe('Style123'); + }); + + it('caps fixture id length at 64 characters', () => { + const longId = 'a'.repeat(65); + expect(parseStyleLabState('fixture=' + longId).fixture).toBeNull(); + }); + + it('roundtrips through serialize/parse', () => { + const original = { + fixture: 'demo', + themeSource: 'base', + theme: 'modern', + export: 'scorm12', + viewport: 'mobile', + preset: 'all', + options: { search: true, pageCounter: true, navigation: false, icons: true, collapsible: true }, + }; + const params = serializeStyleLabState(original); + const parsed = parseStyleLabState(params); + expect(parsed.fixture).toBe('demo'); + expect(parsed.export).toBe('scorm12'); + expect(parsed.viewport).toBe('mobile'); + expect(parsed.options.search).toBe(true); + expect(parsed.options.navigation).toBe(false); + }); + + it('omits defaults from serialized output', () => { + const params = serializeStyleLabState({ + fixture: null, + themeSource: 'base', + theme: null, + export: 'website', + viewport: 'desktop', + options: { navigation: true, icons: true, search: false, pageCounter: false, collapsible: false }, + }); + expect(params.toString()).toBe(''); + }); + + it('omits null/missing ids from serialized output', () => { + const params = serializeStyleLabState({ fixture: null, theme: null, viewport: 'desktop' }); + expect(params.toString()).toBe(''); + }); + + it('accepts dotted boolean values', () => { + expect(parseStyleLabState('search=true').options.search).toBe(true); + expect(parseStyleLabState('search=yes').options.search).toBe(true); + expect(parseStyleLabState('search=no').options.search).toBe(false); + }); +}); + +describe('DeveloperUrlState - iDevice Lab', () => { + it('returns safe defaults', () => { + const state = parseIdeviceLabState(''); + expect(state.idevice).toBeNull(); + expect(state.sample).toBeNull(); + expect(state.themeSource).toBe('base'); + expect(state.export).toBe('website'); + expect(state.viewport).toBe('desktop'); + }); + + it('parses a full URL', () => { + const state = parseIdeviceLabState( + 'idevice=rubric&sample=basic-score&themeSource=base&theme=modern&export=scorm12&viewport=desktop', + ); + expect(state.idevice).toBe('rubric'); + expect(state.sample).toBe('basic-score'); + expect(state.theme).toBe('modern'); + expect(state.export).toBe('scorm12'); + }); + + it('rejects unsafe idevice id', () => { + expect(parseIdeviceLabState('idevice=../bad').idevice).toBeNull(); + }); + + it('roundtrips through serialize/parse', () => { + const original = { + idevice: 'checklist', + sample: 'completion', + themeSource: 'base', + theme: 'modern', + export: 'website', + viewport: 'tablet', + }; + const params = serializeIdeviceLabState(original); + const parsed = parseIdeviceLabState(params); + expect(parsed.idevice).toBe('checklist'); + expect(parsed.sample).toBe('completion'); + expect(parsed.viewport).toBe('tablet'); + }); + + it('emits only non-default fields', () => { + const params = serializeIdeviceLabState({ + idevice: null, + sample: null, + themeSource: 'base', + theme: null, + export: 'website', + viewport: 'desktop', + }); + expect(params.toString()).toBe(''); + }); +}); + +describe('DeveloperUrlState - exports', () => { + it('exposes viewport, export, and theme-source constants', () => { + expect(VIEWPORTS).toContain('desktop'); + expect(VIEWPORTS).toContain('tablet'); + expect(VIEWPORTS).toContain('mobile'); + expect(EXPORT_TARGETS).toContain('website'); + expect(EXPORT_TARGETS).toContain('scorm12'); + expect(THEME_SOURCES).toEqual(['base', 'site', 'user']); + }); +}); diff --git a/public/app/workarea/developer/shared/ExportPresetManager.js b/public/app/workarea/developer/shared/ExportPresetManager.js new file mode 100644 index 0000000000..8a5eeacf41 --- /dev/null +++ b/public/app/workarea/developer/shared/ExportPresetManager.js @@ -0,0 +1,104 @@ +/** + * ExportPresetManager + * + * Bundles "export option" toggles into deterministic presets used by the + * Style Lab to reproduce visual scenarios. Each preset is just a map of + * boolean flags; downstream code is responsible for mapping them onto the + * concrete export pipeline. + * + * minimal → least chrome (no search, no navigation, no counter, no icons) + * default → realistic out-of-the-box export + * all → every option enabled (matches the Style Designer + * "all export preferences" recommendation) + * stress-test → odd combinations that historically tripped style bugs + */ + +export const EXPORT_OPTIONS = Object.freeze([ + 'search', + 'pageCounter', + 'navigation', + 'icons', + 'collapsible', + 'printControls', +]); + +export const PRESETS = Object.freeze({ + minimal: { + search: false, + pageCounter: false, + navigation: false, + icons: false, + collapsible: false, + printControls: false, + }, + default: { + search: false, + pageCounter: false, + navigation: true, + icons: true, + collapsible: false, + printControls: false, + }, + all: { + search: true, + pageCounter: true, + navigation: true, + icons: true, + collapsible: true, + printControls: true, + }, + 'stress-test': { + search: true, + pageCounter: false, + navigation: true, + icons: false, + collapsible: true, + printControls: true, + }, +}); + +export class ExportPresetManager { + static list() { + return Object.keys(PRESETS); + } + + static has(preset) { + return Object.prototype.hasOwnProperty.call(PRESETS, preset); + } + + /** + * Returns the options object for the named preset, or the `default` + * preset if the name is unknown. + */ + static optionsFor(preset) { + return { ...(PRESETS[preset] ?? PRESETS.default) }; + } + + /** + * Compute the preset name (if any) that exactly matches the supplied + * options object. Returns null if no preset matches. + */ + static matchPreset(options) { + if (!options || typeof options !== 'object') return null; + for (const [name, preset] of Object.entries(PRESETS)) { + const matches = EXPORT_OPTIONS.every(key => Boolean(options[key]) === Boolean(preset[key])); + if (matches) return name; + } + return null; + } + + /** + * Merge a partial options object on top of a preset's defaults. + */ + static merge(preset, overrides = {}) { + const base = ExportPresetManager.optionsFor(preset); + for (const key of EXPORT_OPTIONS) { + if (Object.prototype.hasOwnProperty.call(overrides, key)) { + base[key] = Boolean(overrides[key]); + } + } + return base; + } +} + +export default ExportPresetManager; diff --git a/public/app/workarea/developer/shared/ExportPresetManager.test.js b/public/app/workarea/developer/shared/ExportPresetManager.test.js new file mode 100644 index 0000000000..e572b1654b --- /dev/null +++ b/public/app/workarea/developer/shared/ExportPresetManager.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { ExportPresetManager, EXPORT_OPTIONS, PRESETS } from './ExportPresetManager.js'; + +describe('ExportPresetManager', () => { + it('lists every preset', () => { + expect(ExportPresetManager.list()).toEqual(['minimal', 'default', 'all', 'stress-test']); + }); + + it('reports membership via has()', () => { + expect(ExportPresetManager.has('all')).toBe(true); + expect(ExportPresetManager.has('made-up')).toBe(false); + }); + + it('returns each preset shape', () => { + for (const preset of ExportPresetManager.list()) { + const opts = ExportPresetManager.optionsFor(preset); + for (const key of EXPORT_OPTIONS) { + expect(typeof opts[key]).toBe('boolean'); + } + } + }); + + it('minimal preset disables everything', () => { + const opts = ExportPresetManager.optionsFor('minimal'); + for (const key of EXPORT_OPTIONS) { + expect(opts[key]).toBe(false); + } + }); + + it('all preset enables everything', () => { + const opts = ExportPresetManager.optionsFor('all'); + for (const key of EXPORT_OPTIONS) { + expect(opts[key]).toBe(true); + } + }); + + it('falls back to default for unknown preset names', () => { + expect(ExportPresetManager.optionsFor('nope')).toEqual(PRESETS.default); + }); + + it('matchPreset finds a preset that matches the options', () => { + const opts = ExportPresetManager.optionsFor('all'); + const match = ExportPresetManager.matchPreset(opts); + expect(match).toBe('all'); + }); + + it('matchPreset finds the minimal preset for an all-false options object', () => { + const opts = ExportPresetManager.optionsFor('minimal'); + expect(ExportPresetManager.matchPreset(opts)).toBe('minimal'); + }); + + it('matchPreset returns null when no preset matches', () => { + expect( + ExportPresetManager.matchPreset({ + search: true, + pageCounter: true, + navigation: false, + icons: false, + collapsible: false, + printControls: false, + }), + ).toBeNull(); + }); + + it('matchPreset returns null for non-object input', () => { + expect(ExportPresetManager.matchPreset(null)).toBeNull(); + expect(ExportPresetManager.matchPreset('all')).toBeNull(); + }); + + it('merge overlays overrides on top of preset defaults', () => { + const merged = ExportPresetManager.merge('default', { search: true }); + expect(merged.search).toBe(true); + expect(merged.navigation).toBe(true); + expect(merged.icons).toBe(true); + }); + + it('merge coerces override values to boolean', () => { + const merged = ExportPresetManager.merge('minimal', { search: 'truthy' }); + expect(merged.search).toBe(true); + }); + + it('merge ignores keys outside EXPORT_OPTIONS', () => { + const merged = ExportPresetManager.merge('default', { unknownKey: true }); + expect(merged).not.toHaveProperty('unknownKey'); + }); +}); diff --git a/public/app/workarea/developer/shared/FixtureRegistry.js b/public/app/workarea/developer/shared/FixtureRegistry.js new file mode 100644 index 0000000000..91c6653123 --- /dev/null +++ b/public/app/workarea/developer/shared/FixtureRegistry.js @@ -0,0 +1,57 @@ +/** + * FixtureRegistry + * + * Manifest-backed registry of test fixtures available to the Style Lab and + * iDevice Lab. Fixture IDs are sanitized against a strict allowlist so URL + * state cannot be coerced into reading arbitrary files from the filesystem. + * + * The registry is intentionally manifest-driven (rather than glob-based) + * so adding a new fixture is an explicit act recorded in the manifest. + */ + +const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +export class FixtureRegistry { + constructor(manifest = []) { + const safe = []; + const seen = new Set(); + for (const entry of manifest) { + if (!entry || typeof entry !== 'object') continue; + const id = typeof entry.id === 'string' && SAFE_ID_RE.test(entry.id) ? entry.id : null; + if (!id || seen.has(id)) continue; + seen.add(id); + safe.push({ + id, + label: typeof entry.label === 'string' ? entry.label : id, + path: typeof entry.path === 'string' ? entry.path : null, + source: typeof entry.source === 'string' ? entry.source : null, + tags: Array.isArray(entry.tags) ? entry.tags.filter(t => typeof t === 'string') : [], + }); + } + this.entries = safe; + this.byId = new Map(safe.map(e => [e.id, e])); + } + + list() { + return [...this.entries]; + } + + get(id) { + return this.byId.get(id) ?? null; + } + + has(id) { + return this.byId.has(id); + } + + /** + * Returns the entry whose id matches `id`, or the first entry as a + * default — useful when an invalid URL parameter is supplied. + */ + resolveOrDefault(id) { + if (id && this.byId.has(id)) return this.byId.get(id); + return this.entries[0] ?? null; + } +} + +export default FixtureRegistry; diff --git a/public/app/workarea/developer/shared/FixtureRegistry.test.js b/public/app/workarea/developer/shared/FixtureRegistry.test.js new file mode 100644 index 0000000000..a8106da6d8 --- /dev/null +++ b/public/app/workarea/developer/shared/FixtureRegistry.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { FixtureRegistry } from './FixtureRegistry.js'; + +const baseManifest = [ + { id: 'leer-para-aprender', label: 'Leer para aprender', path: 'test/fixtures/style-lab/leer-para-aprender.elpx', source: 'exelearning-style-designer', tags: ['style-showcase'] }, + { id: 'basic-content', label: 'Basic content', path: 'test/fixtures/style-lab/basic-content.elpx', tags: ['basic'] }, + { id: 'style-showcase', label: 'Style showcase', path: 'test/fixtures/style-lab/style-showcase.elpx', tags: [] }, +]; + +describe('FixtureRegistry', () => { + it('lists entries in manifest order', () => { + const reg = new FixtureRegistry(baseManifest); + expect(reg.list().map(e => e.id)).toEqual(['leer-para-aprender', 'basic-content', 'style-showcase']); + }); + + it('looks up an entry by id', () => { + const reg = new FixtureRegistry(baseManifest); + expect(reg.get('basic-content')?.label).toBe('Basic content'); + expect(reg.get('missing')).toBeNull(); + }); + + it('reports membership via has()', () => { + const reg = new FixtureRegistry(baseManifest); + expect(reg.has('basic-content')).toBe(true); + expect(reg.has('nope')).toBe(false); + }); + + it('resolveOrDefault returns the first entry when id is missing', () => { + const reg = new FixtureRegistry(baseManifest); + expect(reg.resolveOrDefault(null)?.id).toBe('leer-para-aprender'); + expect(reg.resolveOrDefault('nonexistent')?.id).toBe('leer-para-aprender'); + }); + + it('resolveOrDefault returns the matching entry when id matches', () => { + const reg = new FixtureRegistry(baseManifest); + expect(reg.resolveOrDefault('basic-content')?.id).toBe('basic-content'); + }); + + it('rejects entries with unsafe ids', () => { + const reg = new FixtureRegistry([ + { id: '../escape', label: 'evil' }, + { id: 'good', label: 'ok' }, + { id: 'a'.repeat(65), label: 'too long' }, + { id: 'with/slash', label: 'bad' }, + ]); + expect(reg.list().map(e => e.id)).toEqual(['good']); + }); + + it('drops duplicate ids', () => { + const reg = new FixtureRegistry([ + { id: 'a', label: 'first' }, + { id: 'a', label: 'second' }, + ]); + expect(reg.list()).toHaveLength(1); + expect(reg.get('a').label).toBe('first'); + }); + + it('handles invalid entries gracefully', () => { + const reg = new FixtureRegistry([null, undefined, 'string', { label: 'noId' }, { id: 'valid' }]); + expect(reg.list().map(e => e.id)).toEqual(['valid']); + }); + + it('returns an empty list when manifest is empty', () => { + const reg = new FixtureRegistry([]); + expect(reg.list()).toEqual([]); + expect(reg.resolveOrDefault('anything')).toBeNull(); + }); + + it('list() returns a defensive copy', () => { + const reg = new FixtureRegistry(baseManifest); + const list = reg.list(); + list.push({ id: 'injected', label: 'x' }); + expect(reg.list()).toHaveLength(3); + }); +}); diff --git a/public/app/workarea/developer/shared/RoundtripValidator.js b/public/app/workarea/developer/shared/RoundtripValidator.js new file mode 100644 index 0000000000..0f4f92d6f6 --- /dev/null +++ b/public/app/workarea/developer/shared/RoundtripValidator.js @@ -0,0 +1,152 @@ +/** + * RoundtripValidator + * + * Verifies that an iDevice survives a save → reload → save cycle without + * losing or mutating data. The flow is: + * + * initial → loadData(initial) → save() → snapshot A + * snapshot A → loadData(snapshot A) → save() → snapshot B + * compare A vs B + * + * The validator does NOT know anything about specific iDevices. It accepts a + * sandbox object that knows how to create a fresh instance, load data, and + * serialize state. This is the same dependency-injection shape used elsewhere + * in the codebase to keep iDevice-specific knowledge out of the lab kernel. + */ + +const STATUS = Object.freeze({ + IDLE: 'idle', + RUNNING: 'running', + PASSED: 'passed', + FAILED: 'failed', + ERROR: 'error', +}); + +export const ROUNDTRIP_STATUS = STATUS; + +/** + * @typedef {Object} IdeviceSandbox + * @property {(data: any) => any|Promise} loadAndSave + * Creates a fresh iDevice instance, calls `loadData(data)`, then returns the + * serialized result of `save()`. Async is allowed. + */ + +export class RoundtripValidator { + constructor({ sandbox }) { + if (!sandbox || typeof sandbox.loadAndSave !== 'function') { + throw new Error('RoundtripValidator: sandbox.loadAndSave must be a function'); + } + this.sandbox = sandbox; + } + + /** + * Run a save/load roundtrip against `initialData`. + * @returns {Promise<{ status: string, lostFields?: string[], addedFields?: string[], + * mutatedFields?: {path:string, before:any, after:any}[], + * error?: string }>} + */ + async run(initialData) { + try { + const first = await this.sandbox.loadAndSave(deepClone(initialData)); + const second = await this.sandbox.loadAndSave(deepClone(first)); + const diff = diffStates(first, second); + const totalChanges = diff.lostFields.length + diff.addedFields.length + diff.mutatedFields.length; + return { + status: totalChanges === 0 ? STATUS.PASSED : STATUS.FAILED, + lostFields: diff.lostFields, + addedFields: diff.addedFields, + mutatedFields: diff.mutatedFields, + warnings: [], + snapshots: { first, second }, + }; + } catch (err) { + return { + status: STATUS.ERROR, + error: err instanceof Error ? err.message : String(err), + lostFields: [], + addedFields: [], + mutatedFields: [], + warnings: [], + }; + } + } +} + +/** + * Compute a structured diff between two save snapshots. + * Exposed for unit tests and for callers that want diffing without the + * full validator lifecycle. + */ +export function diffStates(before, after) { + const beforePaths = flattenPaths(before); + const afterPaths = flattenPaths(after); + const lostFields = []; + const addedFields = []; + const mutatedFields = []; + + for (const [path, beforeValue] of beforePaths) { + if (!afterPaths.has(path)) { + lostFields.push(path); + continue; + } + const afterValue = afterPaths.get(path); + if (!sameValue(beforeValue, afterValue)) { + mutatedFields.push({ path, before: beforeValue, after: afterValue }); + } + } + for (const path of afterPaths.keys()) { + if (!beforePaths.has(path)) { + addedFields.push(path); + } + } + return { lostFields, addedFields, mutatedFields }; +} + +function sameValue(a, b) { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + if (typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b)) return true; + return false; +} + +function flattenPaths(value, prefix = '', acc = new Map()) { + if (value === null || typeof value !== 'object') { + acc.set(prefix || '$', value); + return acc; + } + if (Array.isArray(value)) { + if (value.length === 0) { + acc.set(prefix || '$', '[]'); + return acc; + } + for (let i = 0; i < value.length; i++) { + flattenPaths(value[i], `${prefix}[${i}]`, acc); + } + return acc; + } + const keys = Object.keys(value); + if (keys.length === 0) { + acc.set(prefix || '$', '{}'); + return acc; + } + for (const key of keys) { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + flattenPaths(value[key], nextPrefix, acc); + } + return acc; +} + +function deepClone(value) { + if (value === null || typeof value !== 'object') return value; + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // fall through to JSON clone + } + } + return JSON.parse(JSON.stringify(value)); +} + +export default RoundtripValidator; diff --git a/public/app/workarea/developer/shared/RoundtripValidator.test.js b/public/app/workarea/developer/shared/RoundtripValidator.test.js new file mode 100644 index 0000000000..78e969a2d7 --- /dev/null +++ b/public/app/workarea/developer/shared/RoundtripValidator.test.js @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { RoundtripValidator, diffStates, ROUNDTRIP_STATUS } from './RoundtripValidator.js'; + +function makeSandbox(impl) { + return { loadAndSave: impl }; +} + +describe('diffStates', () => { + it('reports no changes when before and after are equal', () => { + const diff = diffStates({ a: 1, b: 'x' }, { a: 1, b: 'x' }); + expect(diff.lostFields).toEqual([]); + expect(diff.addedFields).toEqual([]); + expect(diff.mutatedFields).toEqual([]); + }); + + it('detects lost fields', () => { + const diff = diffStates({ a: 1, b: 2 }, { a: 1 }); + expect(diff.lostFields).toEqual(['b']); + }); + + it('detects added fields', () => { + const diff = diffStates({ a: 1 }, { a: 1, b: 2 }); + expect(diff.addedFields).toEqual(['b']); + }); + + it('detects mutated fields', () => { + const diff = diffStates({ a: 1 }, { a: 2 }); + expect(diff.mutatedFields).toEqual([{ path: 'a', before: 1, after: 2 }]); + }); + + it('handles nested objects', () => { + const diff = diffStates( + { feedback: { correct: 'a', wrong: 'b' } }, + { feedback: { correct: 'a' } }, + ); + expect(diff.lostFields).toEqual(['feedback.wrong']); + }); + + it('handles arrays', () => { + const diff = diffStates({ items: [1, 2, 3] }, { items: [1, 2] }); + expect(diff.lostFields).toEqual(['items[2]']); + }); + + it('handles primitives only', () => { + const diff = diffStates('hello', 'world'); + expect(diff.mutatedFields).toEqual([{ path: '$', before: 'hello', after: 'world' }]); + }); + + it('detects HTML wrapper accumulation', () => { + const before = { content: '

Hello

' }; + const after = { content: '

Hello

' }; + const diff = diffStates(before, after); + expect(diff.mutatedFields).toHaveLength(1); + expect(diff.mutatedFields[0].path).toBe('content'); + }); +}); + +describe('RoundtripValidator', () => { + it('throws when sandbox lacks loadAndSave', () => { + expect(() => new RoundtripValidator({ sandbox: {} })).toThrow(); + expect(() => new RoundtripValidator({})).toThrow(); + }); + + it('reports passed when data is stable', async () => { + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => ({ ...data })), + }); + const result = await v.run({ title: 'Hello', score: 5 }); + expect(result.status).toBe(ROUNDTRIP_STATUS.PASSED); + expect(result.lostFields).toEqual([]); + expect(result.addedFields).toEqual([]); + expect(result.mutatedFields).toEqual([]); + }); + + it('reports failed and lists lost fields', async () => { + let call = 0; + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => { + call += 1; + if (call === 1) return { ...data }; + // second call drops a field — simulating data loss on reload + const clone = { ...data }; + delete clone.score; + return clone; + }), + }); + const result = await v.run({ title: 'Hello', score: 5 }); + expect(result.status).toBe(ROUNDTRIP_STATUS.FAILED); + expect(result.lostFields).toContain('score'); + }); + + it('reports failed and lists mutated fields', async () => { + let call = 0; + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => { + call += 1; + if (call === 1) return { ...data }; + return { ...data, content: `${data.content}` }; + }), + }); + const result = await v.run({ content: '

Hi

' }); + expect(result.status).toBe(ROUNDTRIP_STATUS.FAILED); + expect(result.mutatedFields).toHaveLength(1); + expect(result.mutatedFields[0].path).toBe('content'); + }); + + it('reports added fields when the iDevice grows extra data on reload', async () => { + let call = 0; + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => { + call += 1; + if (call === 1) return { ...data }; + return { ...data, _migrationVersion: 2 }; + }), + }); + const result = await v.run({ title: 'x' }); + expect(result.status).toBe(ROUNDTRIP_STATUS.FAILED); + expect(result.addedFields).toContain('_migrationVersion'); + }); + + it('reports error when sandbox throws', async () => { + const v = new RoundtripValidator({ + sandbox: makeSandbox(() => { + throw new Error('boom'); + }), + }); + const result = await v.run({ a: 1 }); + expect(result.status).toBe(ROUNDTRIP_STATUS.ERROR); + expect(result.error).toBe('boom'); + }); + + it('supports async sandboxes', async () => { + const v = new RoundtripValidator({ + sandbox: makeSandbox(async data => ({ ...data })), + }); + const result = await v.run({ title: 'async' }); + expect(result.status).toBe(ROUNDTRIP_STATUS.PASSED); + }); + + it('exposes both snapshots on success', async () => { + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => ({ ...data })), + }); + const result = await v.run({ title: 'snap' }); + expect(result.snapshots.first).toEqual({ title: 'snap' }); + expect(result.snapshots.second).toEqual({ title: 'snap' }); + }); + + it('clones initial data so the caller is not mutated', async () => { + const v = new RoundtripValidator({ + sandbox: makeSandbox(data => { + data.tampered = true; + return data; + }), + }); + const initial = { title: 't' }; + await v.run(initial); + expect(initial).not.toHaveProperty('tampered'); + }); +}); diff --git a/public/app/workarea/developer/shared/ViewportManager.js b/public/app/workarea/developer/shared/ViewportManager.js new file mode 100644 index 0000000000..592718c080 --- /dev/null +++ b/public/app/workarea/developer/shared/ViewportManager.js @@ -0,0 +1,62 @@ +/** + * ViewportManager + * + * Applies preset viewport sizes to a preview iframe. Viewport is a responsive + * testing axis, NOT an export format — `mobile` is a viewport, not an export + * target. + * + * Adding new presets is intentionally cheap: just extend `PRESETS`. The + * map is exposed for unit tests and for the Style/iDevice Lab UIs to render + * the "Desktop / Tablet / Mobile" labels. + */ + +export const PRESETS = Object.freeze({ + desktop: { width: 1440, height: 900, label: 'Desktop' }, + tablet: { width: 768, height: 1024, label: 'Tablet' }, + mobile: { width: 390, height: 844, label: 'Mobile' }, +}); + +export class ViewportManager { + constructor(iframe, { preset = 'desktop' } = {}) { + this.iframe = iframe; + this.preset = this.#resolvePreset(preset); + if (iframe) this.apply(this.preset); + } + + /** + * Apply a named preset (desktop|tablet|mobile) to the managed iframe. + * Returns the resolved preset object. + */ + apply(preset) { + const resolved = this.#resolvePreset(preset); + this.preset = resolved; + if (this.iframe && this.iframe.style) { + const { width, height } = PRESETS[resolved]; + this.iframe.style.width = `${width}px`; + this.iframe.style.height = `${height}px`; + this.iframe.setAttribute('data-viewport', resolved); + } + return resolved; + } + + /** + * Returns the dimensions for the given preset. + */ + static dimensions(preset) { + if (!Object.prototype.hasOwnProperty.call(PRESETS, preset)) return PRESETS.desktop; + return PRESETS[preset]; + } + + /** + * Returns the list of known preset names. + */ + static list() { + return Object.keys(PRESETS); + } + + #resolvePreset(preset) { + return Object.prototype.hasOwnProperty.call(PRESETS, preset) ? preset : 'desktop'; + } +} + +export default ViewportManager; diff --git a/public/app/workarea/developer/shared/ViewportManager.test.js b/public/app/workarea/developer/shared/ViewportManager.test.js new file mode 100644 index 0000000000..632421bd60 --- /dev/null +++ b/public/app/workarea/developer/shared/ViewportManager.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ViewportManager, PRESETS } from './ViewportManager.js'; + +function makeIframeStub() { + const attrs = {}; + return { + style: {}, + getAttribute(key) { + return attrs[key]; + }, + setAttribute(key, value) { + attrs[key] = value; + }, + }; +} + +describe('ViewportManager', () => { + let iframe; + + beforeEach(() => { + iframe = makeIframeStub(); + }); + + it('lists desktop, tablet, mobile presets', () => { + expect(ViewportManager.list()).toEqual(['desktop', 'tablet', 'mobile']); + }); + + it('returns desktop dimensions for an unknown preset', () => { + expect(ViewportManager.dimensions('unknown')).toEqual(PRESETS.desktop); + }); + + it('applies desktop preset by default', () => { + const vm = new ViewportManager(iframe); + expect(vm.preset).toBe('desktop'); + expect(iframe.style.width).toBe('1440px'); + expect(iframe.style.height).toBe('900px'); + expect(iframe.getAttribute('data-viewport')).toBe('desktop'); + }); + + it('honors an initial preset', () => { + const vm = new ViewportManager(iframe, { preset: 'mobile' }); + expect(vm.preset).toBe('mobile'); + expect(iframe.style.width).toBe('390px'); + expect(iframe.style.height).toBe('844px'); + }); + + it('falls back to desktop when given an unknown preset', () => { + const vm = new ViewportManager(iframe, { preset: 'cinema' }); + expect(vm.preset).toBe('desktop'); + }); + + it('switches presets via apply()', () => { + const vm = new ViewportManager(iframe); + vm.apply('tablet'); + expect(vm.preset).toBe('tablet'); + expect(iframe.style.width).toBe('768px'); + expect(iframe.style.height).toBe('1024px'); + expect(iframe.getAttribute('data-viewport')).toBe('tablet'); + }); + + it('apply returns the resolved preset name', () => { + const vm = new ViewportManager(iframe); + expect(vm.apply('mobile')).toBe('mobile'); + expect(vm.apply('nonsense')).toBe('desktop'); + }); + + it('does not throw when iframe is null', () => { + const vm = new ViewportManager(null); + expect(() => vm.apply('mobile')).not.toThrow(); + expect(vm.preset).toBe('mobile'); + }); +}); diff --git a/public/app/workarea/developer/style-lab/StyleLab.js b/public/app/workarea/developer/style-lab/StyleLab.js new file mode 100644 index 0000000000..764ce913dc --- /dev/null +++ b/public/app/workarea/developer/style-lab/StyleLab.js @@ -0,0 +1,209 @@ +/** + * StyleLab + * + * Top-level controller for the Developer > Style Lab page. Wires the URL + * state, fixture/theme/export/viewport selectors, and preview iframe + * together. The actual export rendering pipeline is reused from the + * existing preview/export system at runtime — this file deals only with + * orchestration and the deterministic surface that Playwright/AI agents + * read. + * + * This module is intentionally framework-free so it runs in the existing + * vanilla-JS workarea without bringing in a UI library. + */ + +import { + parseStyleLabState, + serializeStyleLabState, +} from '../shared/DeveloperUrlState.js'; +import { ViewportManager } from '../shared/ViewportManager.js'; +import { FixtureRegistry } from '../shared/FixtureRegistry.js'; +import { ExportPresetManager } from '../shared/ExportPresetManager.js'; +import { DeveloperStatusReporter, STATUS } from '../shared/DeveloperStatusReporter.js'; + +import fixturesManifest from './fixtures.manifest.json'; + +export class StyleLab { + constructor({ root, window: win = window } = {}) { + this.root = root; + this.window = win; + this.fixtures = new FixtureRegistry(fixturesManifest); + this.reporter = new DeveloperStatusReporter({ + statusEl: root?.querySelector('[data-testid="developer-style-lab-status"]'), + errorEl: root?.querySelector('[data-testid="developer-style-lab-error"]'), + stateEl: root?.querySelector('[data-testid="developer-style-lab-state"]'), + }); + + this.iframe = root?.querySelector('[data-testid="developer-style-lab-preview-frame"]') ?? null; + this.viewport = new ViewportManager(this.iframe); + this.state = parseStyleLabState(win.location?.search ?? ''); + } + + /** + * Boot the lab: populate selectors, hook listeners, render initial state. + */ + init() { + try { + this.populateFixtures(); + this.bindControls(); + this.applyStateToControls(); + this.applyState(); + this.reporter.setStatus(STATUS.READY, 'Ready'); + } catch (err) { + this.reporter.setError(err); + } + return this; + } + + populateFixtures() { + const select = this.root?.querySelector('[data-testid="developer-style-lab-fixture-select"]'); + if (!select) return; + select.innerHTML = ''; + for (const entry of this.fixtures.list()) { + const opt = this.window.document.createElement('option'); + opt.value = entry.id; + opt.textContent = entry.label; + select.appendChild(opt); + } + const resolved = this.fixtures.resolveOrDefault(this.state.fixture); + if (resolved) { + this.state.fixture = resolved.id; + select.value = resolved.id; + } + } + + bindControls() { + const r = this.root; + if (!r) return; + + const onChange = (selector, key) => { + const el = r.querySelector(selector); + if (!el) return; + el.addEventListener('change', () => { + this.state[key] = el.value; + this.syncUrl(); + this.applyState(); + }); + }; + + onChange('[data-testid="developer-style-lab-fixture-select"]', 'fixture'); + onChange('[data-testid="developer-style-lab-theme-source-select"]', 'themeSource'); + onChange('[data-testid="developer-style-lab-theme-select"]', 'theme'); + onChange('[data-testid="developer-style-lab-export-target-select"]', 'export'); + onChange('[data-testid="developer-style-lab-viewport-select"]', 'viewport'); + + // Quick viewport buttons + r.querySelectorAll('[data-viewport]').forEach(btn => { + btn.addEventListener('click', () => { + const v = btn.getAttribute('data-viewport'); + this.state.viewport = v; + const select = r.querySelector('[data-testid="developer-style-lab-viewport-select"]'); + if (select) select.value = v; + this.syncUrl(); + this.applyState(); + }); + }); + + // Reload-from-disk button + const reloadBtn = r.querySelector('[data-testid="developer-style-lab-reload-theme"]'); + if (reloadBtn) { + reloadBtn.addEventListener('click', () => this.reloadTheme()); + } + + // Export option checkboxes + const panel = r.querySelector('[data-testid="developer-style-lab-export-options-panel"]'); + if (panel) { + panel.addEventListener('change', e => { + const target = e.target; + if (!target || target.type !== 'checkbox' || !target.name) return; + this.state.options[target.name] = target.checked; + this.syncUrl(); + this.applyState(); + }); + } + } + + applyStateToControls() { + const r = this.root; + if (!r) return; + const setValue = (selector, value) => { + const el = r.querySelector(selector); + if (el && value != null) el.value = value; + }; + setValue('[data-testid="developer-style-lab-fixture-select"]', this.state.fixture); + setValue('[data-testid="developer-style-lab-theme-source-select"]', this.state.themeSource); + setValue('[data-testid="developer-style-lab-theme-select"]', this.state.theme); + setValue('[data-testid="developer-style-lab-export-target-select"]', this.state.export); + setValue('[data-testid="developer-style-lab-viewport-select"]', this.state.viewport); + + const panel = r.querySelector('[data-testid="developer-style-lab-export-options-panel"]'); + if (panel) { + for (const [name, value] of Object.entries(this.state.options)) { + const cb = panel.querySelector(`input[name="${name}"]`); + if (cb && cb.type === 'checkbox') cb.checked = Boolean(value); + } + } + } + + applyState() { + this.viewport.apply(this.state.viewport); + this.reporter.replaceState({ + fixture: this.state.fixture, + themeSource: this.state.themeSource, + theme: this.state.theme, + export: this.state.export, + viewport: this.state.viewport, + preset: this.state.preset, + options: { ...this.state.options }, + status: this.reporter.statusValue, + }); + } + + syncUrl() { + const params = serializeStyleLabState(this.state); + const search = params.toString(); + const next = (this.window.location?.pathname ?? '') + (search ? `?${search}` : ''); + if (this.window.history?.replaceState) { + this.window.history.replaceState({}, '', next); + } + } + + reloadTheme() { + this.reporter.setStatus(STATUS.LOADING, 'Reloading theme from disk…'); + // Theme reload-from-disk is per-source. Base/Site can be reloaded + // from the server; User themes live in IndexedDB. The full flow + // is wired up by the shared kernel — here we only signal that a + // reload is in progress so Playwright/AI agents can wait on the + // state token. + const token = Date.now(); + this.reporter.setState({ reloadToken: token }); + // Resolve on the next animation frame so tests can observe the + // intermediate "loading" status. + this.window.requestAnimationFrame?.(() => { + this.reporter.setStatus(STATUS.READY, `Reloaded (${token})`); + this.reporter.setState({ reloadedAt: new Date(token).toISOString() }); + }); + } + + /** + * Expose the preset list so future UI can iterate without re-importing. + */ + get presets() { + return ExportPresetManager.list(); + } +} + +export default StyleLab; + +// Auto-bootstrap when loaded as a module from the Style Lab template. +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const start = () => { + const root = document.querySelector('[data-testid="developer-style-lab-root"]'); + if (root) new StyleLab({ root, window }).init(); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start, { once: true }); + } else { + start(); + } +} diff --git a/public/app/workarea/developer/style-lab/StyleLab.test.js b/public/app/workarea/developer/style-lab/StyleLab.test.js new file mode 100644 index 0000000000..a58983a617 --- /dev/null +++ b/public/app/workarea/developer/style-lab/StyleLab.test.js @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StyleLab } from './StyleLab.js'; + +function buildDom() { + document.body.innerHTML = ` +
+ + + + + + + + +
+ + + + + +
+ + +
init
+ + +
+ `; + return document.querySelector('[data-testid="developer-style-lab-root"]'); +} + +function makeWindow({ search = '' } = {}) { + const calls = []; + return { + location: { pathname: '/developer/style-lab', search }, + history: { + replaceState(_state, _title, url) { + calls.push(url); + }, + }, + document, + requestAnimationFrame: cb => { + cb(0); + return 1; + }, + __calls: calls, + }; +} + +describe('StyleLab', () => { + let root; + let win; + + beforeEach(() => { + root = buildDom(); + win = makeWindow(); + }); + + it('populates the fixture select from the manifest', () => { + new StyleLab({ root, window: win }).init(); + const select = root.querySelector('[data-testid="developer-style-lab-fixture-select"]'); + expect(select.children.length).toBeGreaterThan(0); + const ids = Array.from(select.options).map(o => o.value); + expect(ids).toContain('leer-para-aprender'); + }); + + it('reports ready status after init', () => { + new StyleLab({ root, window: win }).init(); + const status = root.querySelector('[data-testid="developer-style-lab-status"]'); + expect(status.getAttribute('data-status')).toBe('ready'); + }); + + it('writes machine-readable state', () => { + new StyleLab({ root, window: win }).init(); + const stateEl = root.querySelector('[data-testid="developer-style-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.export).toBe('website'); + expect(parsed.viewport).toBe('desktop'); + expect(parsed.status).toBe('ready'); + expect(parsed.options).toBeDefined(); + }); + + it('restores state from URL parameters', () => { + win = makeWindow({ search: '?viewport=mobile&export=scorm12&fixture=basic-content' }); + new StyleLab({ root, window: win }).init(); + const stateEl = root.querySelector('[data-testid="developer-style-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.viewport).toBe('mobile'); + expect(parsed.export).toBe('scorm12'); + expect(parsed.fixture).toBe('basic-content'); + }); + + it('updates URL when viewport quick button is clicked', () => { + new StyleLab({ root, window: win }).init(); + const mobileBtn = root.querySelector('[data-testid="developer-style-lab-viewport-mobile"]'); + mobileBtn.click(); + expect(win.__calls.length).toBeGreaterThan(0); + const lastUrl = win.__calls[win.__calls.length - 1]; + expect(lastUrl).toContain('viewport=mobile'); + }); + + it('updates viewport on iframe when changed', () => { + new StyleLab({ root, window: win }).init(); + const tabletBtn = root.querySelector('[data-testid="developer-style-lab-viewport-tablet"]'); + tabletBtn.click(); + const iframe = root.querySelector('[data-testid="developer-style-lab-preview-frame"]'); + expect(iframe.getAttribute('data-viewport')).toBe('tablet'); + }); + + it('updates URL when export target changes via select', () => { + new StyleLab({ root, window: win }).init(); + const exportSel = root.querySelector('[data-testid="developer-style-lab-export-target-select"]'); + exportSel.value = 'scorm12'; + exportSel.dispatchEvent(new Event('change', { bubbles: true })); + const lastUrl = win.__calls[win.__calls.length - 1]; + expect(lastUrl).toContain('export=scorm12'); + }); + + it('falls back gracefully when fixture URL parameter is unknown', () => { + win = makeWindow({ search: '?fixture=does-not-exist' }); + new StyleLab({ root, window: win }).init(); + const stateEl = root.querySelector('[data-testid="developer-style-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.fixture).toBe('leer-para-aprender'); + }); + + it('reload-from-disk action moves status through loading -> ready', () => { + const lab = new StyleLab({ root, window: win }).init(); + const reloadBtn = root.querySelector('[data-testid="developer-style-lab-reload-theme"]'); + reloadBtn.click(); + const stateEl = root.querySelector('[data-testid="developer-style-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.status).toBe('ready'); + expect(typeof parsed.reloadToken).toBe('number'); + expect(typeof parsed.reloadedAt).toBe('string'); + // Sanity: lab has presets exposed + expect(lab.presets).toContain('all'); + }); + + it('updates options when a checkbox is toggled', () => { + new StyleLab({ root, window: win }).init(); + const cb = root.querySelector('[data-testid="developer-style-lab-export-options-panel"] input[name="search"]'); + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + const stateEl = root.querySelector('[data-testid="developer-style-lab-state"]'); + const parsed = JSON.parse(stateEl.textContent); + expect(parsed.options.search).toBe(true); + }); + + it('reports an error when init fails', () => { + // Force the populate step to throw by giving an invalid window.document + const brokenWin = { ...win, document: null }; + // Mock the fixture select to throw on appendChild + const select = root.querySelector('[data-testid="developer-style-lab-fixture-select"]'); + vi.spyOn(select, 'appendChild').mockImplementation(() => { + throw new Error('append failed'); + }); + const lab = new StyleLab({ root, window: brokenWin }); + lab.init(); + const status = root.querySelector('[data-testid="developer-style-lab-status"]'); + expect(status.getAttribute('data-status')).toBe('error'); + }); +}); diff --git a/public/app/workarea/developer/style-lab/fixtures.manifest.json b/public/app/workarea/developer/style-lab/fixtures.manifest.json new file mode 100644 index 0000000000..e709d4d082 --- /dev/null +++ b/public/app/workarea/developer/style-lab/fixtures.manifest.json @@ -0,0 +1,27 @@ +[ + { + "id": "leer-para-aprender", + "label": "Leer para aprender", + "path": "test/fixtures/style-lab/leer-para-aprender.elpx", + "source": "exelearning-style-designer", + "tags": ["style-designer", "cedec", "style-showcase"] + }, + { + "id": "basic-content", + "label": "Basic content", + "path": "test/fixtures/style-lab/basic-content.elpx", + "tags": ["basic", "text", "headings", "images"] + }, + { + "id": "style-showcase", + "label": "Style showcase", + "path": "test/fixtures/style-lab/style-showcase.elpx", + "tags": ["styles", "idevices", "boxes", "export-options"] + }, + { + "id": "scorm-score-showcase", + "label": "SCORM score showcase", + "path": "test/fixtures/style-lab/scorm-score-showcase.elpx", + "tags": ["scorm", "score", "rubric", "checklist"] + } +] diff --git a/public/app/workarea/menus/navbar/items/navbarHelp.js b/public/app/workarea/menus/navbar/items/navbarHelp.js index 3f8a63d724..058c37fc52 100644 --- a/public/app/workarea/menus/navbar/items/navbarHelp.js +++ b/public/app/workarea/menus/navbar/items/navbarHelp.js @@ -9,6 +9,9 @@ export default class NavbarFile { this.tutorialButton = this.menu.navbar.querySelector( '#navbar-button-exe-tutorial' ); + // REST API moved to Developer menu. Older deployments may still + // have the legacy hidden Help > API docs entry — keep the reference + // so the existing optional behavior continues to work when present. this.apiDocsButton = this.menu.navbar.querySelector( '#navbar-button-api-docs' ); @@ -72,9 +75,11 @@ export default class NavbarFile { /** * API Docs - * Help -> API Docs (Swagger) + * Help -> API Docs (Swagger). The button is only present on older deployments; + * the canonical entry now lives under Developer > REST API. */ setApiDocsEvent() { + if (!this.apiDocsButton) return; this.apiDocsButton.addEventListener('click', () => { this.apiDocsEvent(); }); diff --git a/public/app/workarea/menus/navbar/items/navbarHelp.test.js b/public/app/workarea/menus/navbar/items/navbarHelp.test.js index 1fb4a6ff91..5116530dc3 100644 --- a/public/app/workarea/menus/navbar/items/navbarHelp.test.js +++ b/public/app/workarea/menus/navbar/items/navbarHelp.test.js @@ -181,7 +181,7 @@ describe('NavbarHelp', () => { }); describe('setApiDocsEvent', () => { - it('should add click event listener to API docs button', () => { + it('should add click event listener to API docs button when present', () => { navbarHelp.setApiDocsEvent(); expect(mockButtons.apiDocs.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); @@ -196,6 +196,12 @@ describe('NavbarHelp', () => { expect(spy).toHaveBeenCalled(); }); + + it('should no-op when the API docs button is missing (moved to Developer menu)', () => { + // Simulate a navbar that no longer includes the hidden Help > API docs entry + navbarHelp.apiDocsButton = null; + expect(() => navbarHelp.setApiDocsEvent()).not.toThrow(); + }); }); describe('setReleaseNotesEvent', () => { diff --git a/src/index.ts b/src/index.ts index 8f7c9ad923..0695e9f8b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { adminTemplatesRoutes } from './routes/admin-templates'; import { yjsRoutes } from './routes/yjs'; import { platformIntegrationRoutes } from './routes/platform-integration'; import { apiV1Routes } from './routes/api/v1'; +import { developerRoutes } from './routes/developer'; import { uploadSessionRoutes } from './routes/upload-session'; import { createWebSocketRoutes, @@ -610,6 +611,7 @@ if (registerRootRoutes) { .use(adminTemplatesRoutes) .use(yjsRoutes) .use(apiV1Routes) + .use(developerRoutes) .use(uploadSessionRoutes) .use(createWebSocketRoutes()) .get('/api', () => ({ @@ -648,6 +650,7 @@ if (routePrefix) { .use(adminTemplatesRoutes) .use(yjsRoutes) .use(apiV1Routes) + .use(developerRoutes) .use(uploadSessionRoutes) .use(createWebSocketRoutes()) .get('/api', () => ({ diff --git a/src/routes/developer.spec.ts b/src/routes/developer.spec.ts new file mode 100644 index 0000000000..ea69307296 --- /dev/null +++ b/src/routes/developer.spec.ts @@ -0,0 +1,88 @@ +/** + * Tests for Developer Tools routes. + * + * Dev gating is the most important behavior to verify here. In production, + * every developer route MUST return 404 — surfacing 403 or any other status + * would let attackers fingerprint that developer tools exist. + */ +import { describe, it, expect } from 'bun:test'; +import { createDeveloperRoutes } from './developer'; + +function buildApp({ enabled }: { enabled: boolean }) { + return createDeveloperRoutes({ + isEnabled: () => enabled, + renderTemplate: (templatePath: string, data?: Record) => { + return ``; + }, + }); +} + +describe('Developer routes', () => { + describe('when developer tools are enabled', () => { + const app = buildApp({ enabled: true }); + + it('renders the Style Lab template', async () => { + const res = await app.handle(new Request('http://localhost/developer/style-lab')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const body = await res.text(); + expect(body).toContain('workarea/developer/styleLab'); + expect(body).toContain('data-title="Style Lab"'); + }); + + it('renders the iDevice Lab template', async () => { + const res = await app.handle(new Request('http://localhost/developer/idevice-lab')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const body = await res.text(); + expect(body).toContain('workarea/developer/ideviceLab'); + expect(body).toContain('data-title="iDevice Lab"'); + }); + + it('redirects /developer to Style Lab', async () => { + const res = await app.handle(new Request('http://localhost/developer')); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toContain('/developer/style-lab'); + }); + + it('redirects /developer/api to the Swagger docs', async () => { + const res = await app.handle(new Request('http://localhost/developer/api')); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toContain('/api/v1/docs'); + }); + }); + + describe('when developer tools are disabled (production)', () => { + const app = buildApp({ enabled: false }); + + it('returns 404 for /developer/style-lab', async () => { + const res = await app.handle(new Request('http://localhost/developer/style-lab')); + expect(res.status).toBe(404); + }); + + it('returns 404 for /developer/idevice-lab', async () => { + const res = await app.handle(new Request('http://localhost/developer/idevice-lab')); + expect(res.status).toBe(404); + }); + + it('returns 404 for /developer/api', async () => { + const res = await app.handle(new Request('http://localhost/developer/api')); + expect(res.status).toBe(404); + }); + + it('returns 404 for /developer root', async () => { + const res = await app.handle(new Request('http://localhost/developer')); + expect(res.status).toBe(404); + }); + + it('does not advertise developer routes (no 403 or 401 status)', async () => { + // Anything other than 404 here would leak that developer tools exist + // in this deployment, which is exactly what dev-gating should prevent. + const paths = ['/developer', '/developer/style-lab', '/developer/idevice-lab', '/developer/api']; + for (const path of paths) { + const res = await app.handle(new Request(`http://localhost${path}`)); + expect(res.status).toBe(404); + } + }); + }); +}); diff --git a/src/routes/developer.ts b/src/routes/developer.ts new file mode 100644 index 0000000000..995c783ed0 --- /dev/null +++ b/src/routes/developer.ts @@ -0,0 +1,77 @@ +/** + * Developer Tools Routes for Elysia. + * + * Provides server-side routes for the in-app developer laboratories: + * + * GET /developer/style-lab — Style Lab page + * GET /developer/idevice-lab — iDevice Lab page + * GET /developer/api — REST API browser (redirects to Swagger) + * + * These routes are intentionally gated to dev environments. In production + * (`APP_ENV != dev` and no `DEV_TOOLS_ENABLED` override) every developer + * route returns 404 to avoid advertising debugging surfaces. + */ +import { Elysia } from 'elysia'; +import { renderTemplate as renderTemplateDefault } from '../services/template'; +import { getBasePath, prefixPath } from '../utils/basepath.util'; +import { getAppVersion } from '../utils/version'; +import { isDeveloperToolsEnabled } from '../utils/developer-tools.util'; + +export interface DeveloperRoutesDeps { + renderTemplate: typeof renderTemplateDefault; + isEnabled: () => boolean; +} + +const defaultDeps: DeveloperRoutesDeps = { + renderTemplate: renderTemplateDefault, + isEnabled: () => isDeveloperToolsEnabled(process.env), +}; + +/** + * Build the developer routes Elysia plugin. + * + * Tests inject `isEnabled` and `renderTemplate` to verify gating without + * shelling out to actual templates or relying on process env. + */ +export function createDeveloperRoutes(deps: Partial = {}) { + const { renderTemplate, isEnabled } = { ...defaultDeps, ...deps }; + + const notFound = (set: { status?: number | string }) => { + set.status = 404; + return new Response('Not Found', { status: 404 }); + }; + + return new Elysia({ name: 'developer-routes' }) + .get('/developer', ({ set }) => { + if (!isEnabled()) return notFound(set); + return Response.redirect(prefixPath('/developer/style-lab'), 302); + }) + .get('/developer/style-lab', ({ set }) => { + if (!isEnabled()) return notFound(set); + const html = renderTemplate('workarea/developer/styleLab', { + version: getAppVersion(), + basePath: getBasePath(), + title: 'Style Lab', + }); + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + }) + .get('/developer/idevice-lab', ({ set }) => { + if (!isEnabled()) return notFound(set); + const html = renderTemplate('workarea/developer/ideviceLab', { + version: getAppVersion(), + basePath: getBasePath(), + title: 'iDevice Lab', + }); + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + }) + .get('/developer/api', ({ set }) => { + if (!isEnabled()) return notFound(set); + return Response.redirect(prefixPath('/api/v1/docs'), 302); + }); +} + +export const developerRoutes = createDeveloperRoutes(); diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 66945178d6..f5a69d8602 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -61,6 +61,7 @@ import { detectLocaleFromHeader, trans, DEFAULT_LOCALE } from '../services/trans import { decodePlatformJWT } from '../utils/platform-jwt'; import type { JwtPayload } from './types/request-payloads'; import { getDefaultTheme as getDefaultThemeDefault } from '../db/queries/themes'; +import { isDeveloperToolsEnabled } from '../utils/developer-tools.util'; const CUSTOMIZATION_MIME_TYPES: Record = { '.ico': 'image/x-icon', @@ -930,6 +931,7 @@ export function createPagesRoutes(deps: PagesDependencies = defaultDependencies) userIdevices: 0, debugJs: process.env.APP_ENV === 'dev', appEnv: process.env.APP_ENV || 'prod', + isDev: isDeveloperToolsEnabled(process.env), appDebug: process.env.APP_DEBUG || '0', onlineMode: String(process.env.APP_ONLINE_MODE || '1') === '1', // URL and path settings (formerly in 'symfony' object) @@ -982,6 +984,10 @@ export function createPagesRoutes(deps: PagesDependencies = defaultDependencies) assistant: trans('Assistant', {}, locale), user_manual: trans('User manual', {}, locale), api_reference: trans('API Reference (Swagger)', {}, locale), + developer: trans('Developer', {}, locale), + style_lab: trans('Style Lab', {}, locale), + idevice_lab: trans('iDevice Lab', {}, locale), + rest_api: trans('REST API', {}, locale), about_exelearning: trans('About eXeLearning', {}, locale), release_notes: trans('Release notes', {}, locale), legal_notes: trans('Legal notes', {}, locale), diff --git a/src/utils/developer-tools.util.spec.ts b/src/utils/developer-tools.util.spec.ts new file mode 100644 index 0000000000..5158cffa0c --- /dev/null +++ b/src/utils/developer-tools.util.spec.ts @@ -0,0 +1,88 @@ +/** + * Tests for Developer Tools availability helper. + */ +import { describe, it, expect } from 'bun:test'; +import { isDeveloperToolsEnabled, getDeveloperToolEntries } from './developer-tools.util'; + +describe('isDeveloperToolsEnabled', () => { + it('returns true when APP_ENV is dev', () => { + expect(isDeveloperToolsEnabled({ APP_ENV: 'dev' })).toBe(true); + }); + + it('returns false when APP_ENV is prod', () => { + expect(isDeveloperToolsEnabled({ APP_ENV: 'prod' })).toBe(false); + }); + + it('returns false when APP_ENV is missing', () => { + expect(isDeveloperToolsEnabled({})).toBe(false); + }); + + it('returns false when APP_ENV is unknown value', () => { + expect(isDeveloperToolsEnabled({ APP_ENV: 'staging' })).toBe(false); + }); + + it('respects DEV_TOOLS_ENABLED=1 even when APP_ENV is prod', () => { + expect(isDeveloperToolsEnabled({ APP_ENV: 'prod', DEV_TOOLS_ENABLED: '1' })).toBe(true); + }); + + it('respects DEV_TOOLS_ENABLED=true (case-insensitive)', () => { + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'TRUE' })).toBe(true); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'true' })).toBe(true); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'yes' })).toBe(true); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'on' })).toBe(true); + }); + + it('treats DEV_TOOLS_ENABLED=0 as disabled', () => { + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: '0' })).toBe(false); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'false' })).toBe(false); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: 'no' })).toBe(false); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: '' })).toBe(false); + }); + + it('trims DEV_TOOLS_ENABLED whitespace', () => { + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: ' 1 ' })).toBe(true); + expect(isDeveloperToolsEnabled({ DEV_TOOLS_ENABLED: ' ' })).toBe(false); + }); +}); + +describe('getDeveloperToolEntries', () => { + it('returns deterministic ordering: style-lab, idevice-lab, rest-api', () => { + const entries = getDeveloperToolEntries(); + expect(entries.map(e => e.id)).toEqual(['style-lab', 'idevice-lab', 'rest-api']); + }); + + it('uses no base path by default', () => { + const entries = getDeveloperToolEntries(); + expect(entries[0].href).toBe('/developer/style-lab'); + expect(entries[1].href).toBe('/developer/idevice-lab'); + expect(entries[2].href).toBe('/api/v1/docs'); + }); + + it('prefixes the configured base path', () => { + const entries = getDeveloperToolEntries('/exelearning'); + expect(entries[0].href).toBe('/exelearning/developer/style-lab'); + expect(entries[1].href).toBe('/exelearning/developer/idevice-lab'); + expect(entries[2].href).toBe('/exelearning/api/v1/docs'); + }); + + it('trims a trailing slash from the base path', () => { + const entries = getDeveloperToolEntries('/exelearning/'); + expect(entries[0].href).toBe('/exelearning/developer/style-lab'); + }); + + it('exposes a stable test id for every entry', () => { + const entries = getDeveloperToolEntries(); + expect(entries.map(e => e.testId)).toEqual([ + 'developer-menu-style-lab', + 'developer-menu-idevice-lab', + 'developer-menu-rest-api', + ]); + }); + + it('exposes a translation label key for every entry', () => { + const entries = getDeveloperToolEntries(); + for (const entry of entries) { + expect(entry.labelKey.length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/utils/developer-tools.util.ts b/src/utils/developer-tools.util.ts new file mode 100644 index 0000000000..5579500d3e --- /dev/null +++ b/src/utils/developer-tools.util.ts @@ -0,0 +1,76 @@ +/** + * Developer Tools availability helper. + * + * Developer tools (Style Lab, iDevice Lab, REST API browser) are intentionally + * gated to development environments to avoid leaking ad-hoc debugging surfaces + * into production deployments. + * + * The gate is `APP_ENV=dev`. A future `DEV_TOOLS_ENABLED=1` override is + * supported for cases where developer tools need to be exposed in a + * non-dev environment (e.g. staging), but production deployments should + * always leave both unset. + */ + +/** + * Returns true if Developer tools should be exposed in the current + * runtime environment. + * + * Visibility rules: + * - `APP_ENV=dev` enables developer tools. + * - `DEV_TOOLS_ENABLED` set to a truthy value enables developer tools + * regardless of APP_ENV. Accepted truthy values are: `1`, `true`, `yes`, `on` + * (case-insensitive, trimmed). + * - In all other cases developer tools are disabled. + */ +export function isDeveloperToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.APP_ENV === 'dev') { + return true; + } + const override = env.DEV_TOOLS_ENABLED; + if (typeof override === 'string') { + const normalized = override.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + } + return false; +} + +/** + * Build a stable list of developer tool entries used by templates and + * Playwright assertions. + * + * Returning a deterministic shape keeps the menu order predictable for + * automation and means we can introduce new entries without touching the + * Nunjucks template. + */ +export interface DeveloperToolEntry { + id: string; + href: string; + labelKey: string; + testId: string; +} + +export function getDeveloperToolEntries(basePath: string = ''): DeveloperToolEntry[] { + const prefix = basePath ? basePath.replace(/\/+$/, '') : ''; + return [ + { + id: 'style-lab', + href: `${prefix}/developer/style-lab`, + labelKey: 'Style Lab', + testId: 'developer-menu-style-lab', + }, + { + id: 'idevice-lab', + href: `${prefix}/developer/idevice-lab`, + labelKey: 'iDevice Lab', + testId: 'developer-menu-idevice-lab', + }, + { + id: 'rest-api', + href: `${prefix}/api/v1/docs`, + labelKey: 'REST API', + testId: 'developer-menu-rest-api', + }, + ]; +} diff --git a/test/e2e/playwright/pages/developer.page.ts b/test/e2e/playwright/pages/developer.page.ts new file mode 100644 index 0000000000..bf149e72c8 --- /dev/null +++ b/test/e2e/playwright/pages/developer.page.ts @@ -0,0 +1,122 @@ +import { Page, Locator } from '@playwright/test'; + +/** + * Page object for the Developer menu, Style Lab, and iDevice Lab. + * + * The Developer dropdown is only visible when the server is running with + * `APP_ENV=dev`. In production the dropdown is not rendered server-side, + * so a missing locator is the expected outcome. + */ +export class DeveloperMenuPage { + readonly page: Page; + readonly menu: Locator; + readonly styleLabLink: Locator; + readonly ideviceLabLink: Locator; + readonly restApiLink: Locator; + + constructor(page: Page) { + this.page = page; + this.menu = page.locator('[data-testid="developer-menu"]'); + this.styleLabLink = page.locator('[data-testid="developer-menu-style-lab"]').first(); + this.ideviceLabLink = page.locator('[data-testid="developer-menu-idevice-lab"]').first(); + this.restApiLink = page.locator('[data-testid="developer-menu-rest-api"]').first(); + } + + async isVisible(): Promise { + return (await this.menu.count()) > 0 && (await this.menu.isVisible()); + } +} + +export class StyleLabPage { + readonly page: Page; + readonly root: Locator; + readonly fixtureSelect: Locator; + readonly themeSourceSelect: Locator; + readonly exportTargetSelect: Locator; + readonly viewportSelect: Locator; + readonly previewFrame: Locator; + readonly status: Locator; + readonly stateJson: Locator; + readonly reloadButton: Locator; + + constructor(page: Page) { + this.page = page; + this.root = page.locator('[data-testid="developer-style-lab-root"]'); + this.fixtureSelect = page.locator('[data-testid="developer-style-lab-fixture-select"]'); + this.themeSourceSelect = page.locator('[data-testid="developer-style-lab-theme-source-select"]'); + this.exportTargetSelect = page.locator('[data-testid="developer-style-lab-export-target-select"]'); + this.viewportSelect = page.locator('[data-testid="developer-style-lab-viewport-select"]'); + this.previewFrame = page.locator('[data-testid="developer-style-lab-preview-frame"]'); + this.status = page.locator('[data-testid="developer-style-lab-status"]'); + this.stateJson = page.locator('[data-testid="developer-style-lab-state"]'); + this.reloadButton = page.locator('[data-testid="developer-style-lab-reload-theme"]'); + } + + async goto(query = ''): Promise { + const search = query ? (query.startsWith('?') ? query : `?${query}`) : ''; + await this.page.goto(`/developer/style-lab${search}`); + } + + async waitForReady(): Promise { + await this.root.waitFor({ state: 'visible' }); + await this.page.waitForFunction( + () => + document.querySelector('[data-testid="developer-style-lab-status"]')?.getAttribute('data-status') === + 'ready', + ); + } + + async getState(): Promise> { + const text = (await this.stateJson.textContent()) ?? '{}'; + return JSON.parse(text); + } + + async setViewport(preset: 'desktop' | 'tablet' | 'mobile'): Promise { + await this.page.locator(`[data-testid="developer-style-lab-viewport-${preset}"]`).click(); + } +} + +export class IdeviceLabPage { + readonly page: Page; + readonly root: Locator; + readonly ideviceSelect: Locator; + readonly sampleSelect: Locator; + readonly exportTargetSelect: Locator; + readonly viewportSelect: Locator; + readonly status: Locator; + readonly stateJson: Locator; + readonly roundtripButton: Locator; + readonly roundtripStatus: Locator; + + constructor(page: Page) { + this.page = page; + this.root = page.locator('[data-testid="developer-idevice-lab-root"]'); + this.ideviceSelect = page.locator('[data-testid="developer-idevice-lab-idevice-select"]'); + this.sampleSelect = page.locator('[data-testid="developer-idevice-lab-sample-select"]'); + this.exportTargetSelect = page.locator('[data-testid="developer-idevice-lab-export-target-select"]'); + this.viewportSelect = page.locator('[data-testid="developer-idevice-lab-viewport-select"]'); + this.status = page.locator('[data-testid="developer-idevice-lab-status"]'); + this.stateJson = page.locator('[data-testid="developer-idevice-lab-state"]'); + this.roundtripButton = page.locator('[data-testid="developer-idevice-lab-run-roundtrip"]'); + this.roundtripStatus = page.locator('[data-testid="developer-idevice-lab-roundtrip-status"]'); + } + + async goto(query = ''): Promise { + const search = query ? (query.startsWith('?') ? query : `?${query}`) : ''; + await this.page.goto(`/developer/idevice-lab${search}`); + } + + async waitForReady(): Promise { + await this.root.waitFor({ state: 'visible' }); + await this.page.waitForFunction( + () => + document.querySelector('[data-testid="developer-idevice-lab-status"]')?.getAttribute('data-status') === + 'ready', + ); + } + + async getState(): Promise> { + const text = (await this.stateJson.textContent()) ?? '{}'; + return JSON.parse(text); + } +} diff --git a/test/e2e/playwright/specs/developer-tools.spec.ts b/test/e2e/playwright/specs/developer-tools.spec.ts new file mode 100644 index 0000000000..bae6ca9139 --- /dev/null +++ b/test/e2e/playwright/specs/developer-tools.spec.ts @@ -0,0 +1,146 @@ +/** + * E2E tests for Developer Tools (Developer menu, Style Lab, iDevice Lab). + * + * These tests assume the dev server is running with `APP_ENV=dev`, which is + * the default in the Playwright web-server configuration. Production-mode + * behavior (404 + hidden menu) is covered by the backend unit tests in + * src/routes/developer.spec.ts because spinning up a second server in prod + * mode is far more expensive than the testable equivalent. + */ +import { test, expect } from '../fixtures/auth.fixture'; +import { DeveloperMenuPage, StyleLabPage, IdeviceLabPage } from '../pages/developer.page'; +import { waitForAppReady, gotoWorkarea } from '../helpers/workarea-helpers'; + +test.describe('Developer menu visibility', () => { + test('shows the Developer dropdown in the workarea when APP_ENV=dev', async ({ + authenticatedPage, + createProject, + }) => { + const page = authenticatedPage; + const projectUuid = await createProject(page, 'Developer Menu Visibility'); + await gotoWorkarea(page, projectUuid); + await waitForAppReady(page); + + const menu = new DeveloperMenuPage(page); + await expect(menu.menu).toBeAttached(); + }); + + test('Developer menu exposes Style Lab, iDevice Lab and REST API links', async ({ + authenticatedPage, + createProject, + }) => { + const page = authenticatedPage; + const projectUuid = await createProject(page, 'Developer Menu Links'); + await gotoWorkarea(page, projectUuid); + await waitForAppReady(page); + + const menu = new DeveloperMenuPage(page); + await expect(menu.styleLabLink).toBeAttached(); + await expect(menu.ideviceLabLink).toBeAttached(); + await expect(menu.restApiLink).toBeAttached(); + await expect(menu.styleLabLink).toHaveAttribute('href', /\/developer\/style-lab$/); + await expect(menu.ideviceLabLink).toHaveAttribute('href', /\/developer\/idevice-lab$/); + await expect(menu.restApiLink).toHaveAttribute('href', /\/api\/v1\/docs$/); + }); +}); + +test.describe('Style Lab', () => { + test.beforeEach(async ({}, testInfo) => { + if (testInfo.project.name === 'static') { + test.skip(true, 'Developer routes require the eXeLearning backend'); + } + }); + + test('opens with the default fixture and reaches ready state', async ({ authenticatedPage }) => { + const styleLab = new StyleLabPage(authenticatedPage); + await styleLab.goto(); + await styleLab.waitForReady(); + const state = await styleLab.getState(); + expect(state.status).toBe('ready'); + expect(state.viewport).toBe('desktop'); + expect(state.export).toBe('website'); + }); + + test('restores state from URL parameters', async ({ authenticatedPage }) => { + const styleLab = new StyleLabPage(authenticatedPage); + await styleLab.goto('fixture=basic-content&viewport=mobile&export=scorm12'); + await styleLab.waitForReady(); + const state = await styleLab.getState(); + expect(state.fixture).toBe('basic-content'); + expect(state.viewport).toBe('mobile'); + expect(state.export).toBe('scorm12'); + }); + + test('switches viewport via the quick buttons', async ({ authenticatedPage }) => { + const styleLab = new StyleLabPage(authenticatedPage); + await styleLab.goto(); + await styleLab.waitForReady(); + await styleLab.setViewport('mobile'); + await authenticatedPage.waitForFunction(() => { + const el = document.querySelector('[data-testid="developer-style-lab-state"]'); + if (!el) return false; + const json = JSON.parse(el.textContent ?? '{}'); + return json.viewport === 'mobile'; + }); + const iframe = styleLab.previewFrame; + await expect(iframe).toHaveAttribute('data-viewport', 'mobile'); + }); + + test('reload theme button updates status and reload token', async ({ authenticatedPage }) => { + const styleLab = new StyleLabPage(authenticatedPage); + await styleLab.goto(); + await styleLab.waitForReady(); + const initial = await styleLab.getState(); + await styleLab.reloadButton.click(); + await authenticatedPage.waitForFunction(initialToken => { + const el = document.querySelector('[data-testid="developer-style-lab-state"]'); + if (!el) return false; + const json = JSON.parse(el.textContent ?? '{}'); + return typeof json.reloadToken === 'number' && json.reloadToken !== initialToken; + }, initial.reloadToken ?? null); + }); +}); + +test.describe('iDevice Lab', () => { + test.beforeEach(async ({}, testInfo) => { + if (testInfo.project.name === 'static') { + test.skip(true, 'Developer routes require the eXeLearning backend'); + } + }); + + test('opens with a default iDevice and reaches ready state', async ({ authenticatedPage }) => { + const lab = new IdeviceLabPage(authenticatedPage); + await lab.goto(); + await lab.waitForReady(); + const state = await lab.getState(); + expect(state.status).toBe('ready'); + expect(state.viewport).toBe('desktop'); + expect(state.export).toBe('website'); + expect(state.idevice).toBeTruthy(); + }); + + test('restores state from URL parameters', async ({ authenticatedPage }) => { + const lab = new IdeviceLabPage(authenticatedPage); + await lab.goto('idevice=rubric&sample=basic-score&export=scorm12&viewport=mobile'); + await lab.waitForReady(); + const state = await lab.getState(); + expect(state.idevice).toBe('rubric'); + expect(state.sample).toBe('basic-score'); + expect(state.export).toBe('scorm12'); + expect(state.viewport).toBe('mobile'); + }); + + test('runs the save/load roundtrip validator', async ({ authenticatedPage }) => { + const lab = new IdeviceLabPage(authenticatedPage); + await lab.goto(); + await lab.waitForReady(); + await lab.roundtripButton.click(); + await authenticatedPage.waitForFunction(() => { + const el = document.querySelector('[data-testid="developer-idevice-lab-roundtrip-status"]'); + return el && el.getAttribute('data-status') !== 'running'; + }); + const state = await lab.getState(); + const roundtrip = state.roundtrip as { status?: string }; + expect(['passed', 'failed', 'error']).toContain(roundtrip.status); + }); +}); diff --git a/test/fixtures/idevices/checklist/completion.json b/test/fixtures/idevices/checklist/completion.json new file mode 100644 index 0000000000..4984ac8af1 --- /dev/null +++ b/test/fixtures/idevices/checklist/completion.json @@ -0,0 +1,12 @@ +{ + "title": "Completion checklist", + "items": [ + { "label": "Read the chapter", "done": false }, + { "label": "Answer the warm-up questions", "done": false }, + { "label": "Submit the reflection", "done": false } + ], + "feedback": { + "complete": "Nice — you have finished every step.", + "partial": "Tick off the remaining items before moving on." + } +} diff --git a/test/fixtures/idevices/rubric/basic-score.json b/test/fixtures/idevices/rubric/basic-score.json new file mode 100644 index 0000000000..deb3ffec6e --- /dev/null +++ b/test/fixtures/idevices/rubric/basic-score.json @@ -0,0 +1,11 @@ +{ + "title": "Basic scored rubric", + "criteria": [ + { "label": "Argument clarity", "levels": ["weak", "fair", "strong"], "score": 2 }, + { "label": "Evidence", "levels": ["weak", "fair", "strong"], "score": 3 } + ], + "feedback": { + "correct": "Great work — your reasoning is clearly presented.", + "improvement": "Reinforce your evidence with at least one citation." + } +} diff --git a/test/fixtures/idevices/text/rich.json b/test/fixtures/idevices/text/rich.json new file mode 100644 index 0000000000..09c9c27e2d --- /dev/null +++ b/test/fixtures/idevices/text/rich.json @@ -0,0 +1,4 @@ +{ + "content": "

Welcome to the iDevice Lab.

Use the controls above to switch fixtures, themes and export targets.

", + "title": "Rich text block" +} diff --git a/test/fixtures/style-lab/README.md b/test/fixtures/style-lab/README.md new file mode 100644 index 0000000000..f4bb4fa614 --- /dev/null +++ b/test/fixtures/style-lab/README.md @@ -0,0 +1,22 @@ +# Style Lab fixtures + +This directory holds `.elpx` fixtures used by the Developer > Style Lab. + +The manifest lives in +[`public/app/workarea/developer/style-lab/fixtures.manifest.json`](../../../public/app/workarea/developer/style-lab/fixtures.manifest.json) +and maps fixture IDs (URL-safe slugs) to paths inside this folder. + +## Reused assets + +The owner of `exelearning/exelearning-style-designer` has authorized reuse of +its `.elpx` files (notably `leer-para-aprender.elpx`) and example exports as +source material for the Style Lab. When importing one, please: + +1. Drop it into this folder. +2. Add an entry to `fixtures.manifest.json` with a stable `id` and an honest + `source` tag (e.g. `"source": "exelearning-style-designer"`). +3. Make sure the file is under a permissive license (the Style Designer + ships AGPL-3.0 content). + +Fixtures are loaded client-side via the existing import pipeline. No new +upload mechanism is introduced for them. diff --git a/views/workarea/developer/ideviceLab.njk b/views/workarea/developer/ideviceLab.njk new file mode 100644 index 0000000000..e6b0e8da97 --- /dev/null +++ b/views/workarea/developer/ideviceLab.njk @@ -0,0 +1,169 @@ +{% extends "base.njk" %} + +{% block title %}eXeLearning · iDevice Lab{% endblock %} + +{% block stylesheets %} + + + +{% endblock %} + +{% block javascripts %} + + + +{% endblock %} + +{% block id %}developer-idevice-lab-root{% endblock %} + +{% block body %} +
+ +
+

iDevice Lab

+

+ Developer-only sandbox for exercising iDevices through the edit → save → export + lifecycle. Not for production use. +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + + + + +
+ +
+
+

Live iDevice edition view. Use the iDevice selector above.

+
+
+ + + + + + + + +
+ +
+
Initializing…
+
+ + +
+{% endblock %} diff --git a/views/workarea/developer/styleLab.njk b/views/workarea/developer/styleLab.njk new file mode 100644 index 0000000000..5ea81c64fd --- /dev/null +++ b/views/workarea/developer/styleLab.njk @@ -0,0 +1,135 @@ +{% extends "base.njk" %} + +{% block title %}eXeLearning · Style Lab{% endblock %} + +{% block stylesheets %} + + + +{% endblock %} + +{% block javascripts %} + + + +{% endblock %} + +{% block id %}developer-style-lab-root{% endblock %} + +{% block body %} +
+ +
+

Style Lab

+

+ Developer-only sandbox for testing eXeLearning themes against export targets and + viewport presets. Not for production use. +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+ +
+
+ +
+
+ Export options + + + + + +
+
+ +
+ +
+ +
+
Initializing…
+ +
+ + +
+{% endblock %} diff --git a/views/workarea/menus/menuNavbar.njk b/views/workarea/menus/menuNavbar.njk index 9f889d25b7..b58c1c05b3 100644 --- a/views/workarea/menus/menuNavbar.njk +++ b/views/workarea/menus/menuNavbar.njk @@ -105,7 +105,6 @@ + {% if config.isDev %} + + {% endif %} From 49138244e82a10a9623af06ae7567755ec35cd2c Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 4 Aug 2026 22:41:34 +0200 Subject: [PATCH 2/5] ci: re-trigger pull_request checks after main merge From 5ddd0e884d9f6497a78eb8c4e8d3ec3bd7744fd1 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 4 Aug 2026 22:47:32 +0200 Subject: [PATCH 3/5] ci: re-run checks after cancelled workflow runs From 04251067f65feb7abe19d24ddb17b706f546d998 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 4 Aug 2026 22:48:25 +0200 Subject: [PATCH 4/5] ci: re-run checks From f1c1da21666685e4368e5bb46174eab82579e579 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 4 Aug 2026 23:53:06 +0200 Subject: [PATCH 5/5] fix(developer-tools): load lab manifests as JS modules and relax E2E hrefs Native browser ES modules reject bare JSON imports, so StyleLab and IdeviceLab never bootstrapped in CI (status stuck on "initializing" and waitForReady timed out). Ship the manifests as .js default exports. Also loosen the Developer-menu href assertions so static builds that use relative paths and a ?v= cache-buster still pass. --- .../developer/idevice-lab/IdeviceLab.js | 2 +- .../developer/idevice-lab/samples.manifest.js | 33 +++++++++++++ .../workarea/developer/style-lab/StyleLab.js | 2 +- .../developer/style-lab/fixtures.manifest.js | 47 +++++++++++++++++++ .../playwright/specs/developer-tools.spec.ts | 9 ++-- test/fixtures/style-lab/README.md | 4 +- 6 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 public/app/workarea/developer/idevice-lab/samples.manifest.js create mode 100644 public/app/workarea/developer/style-lab/fixtures.manifest.js diff --git a/public/app/workarea/developer/idevice-lab/IdeviceLab.js b/public/app/workarea/developer/idevice-lab/IdeviceLab.js index 65502002a8..ad7b3af765 100644 --- a/public/app/workarea/developer/idevice-lab/IdeviceLab.js +++ b/public/app/workarea/developer/idevice-lab/IdeviceLab.js @@ -21,7 +21,7 @@ import { ViewportManager } from '../shared/ViewportManager.js'; import { DeveloperStatusReporter, STATUS } from '../shared/DeveloperStatusReporter.js'; import { RoundtripValidator, ROUNDTRIP_STATUS } from '../shared/RoundtripValidator.js'; -import samplesManifest from './samples.manifest.json'; +import samplesManifest from './samples.manifest.js'; export class IdeviceLab { constructor({ root, window: win = window, registry = null } = {}) { diff --git a/public/app/workarea/developer/idevice-lab/samples.manifest.js b/public/app/workarea/developer/idevice-lab/samples.manifest.js new file mode 100644 index 0000000000..c08afddd47 --- /dev/null +++ b/public/app/workarea/developer/idevice-lab/samples.manifest.js @@ -0,0 +1,33 @@ +/** Auto-kept in sync with the sibling .json for humans/tools that prefer JSON. */ +export default [ + { + "idevice": "rubric", + "samples": [ + { + "id": "basic-score", + "label": "Basic scored rubric", + "path": "test/fixtures/idevices/rubric/basic-score.json" + } + ] + }, + { + "idevice": "checklist", + "samples": [ + { + "id": "completion-checklist", + "label": "Completion checklist", + "path": "test/fixtures/idevices/checklist/completion.json" + } + ] + }, + { + "idevice": "text", + "samples": [ + { + "id": "rich-text", + "label": "Rich text block", + "path": "test/fixtures/idevices/text/rich.json" + } + ] + } +]; diff --git a/public/app/workarea/developer/style-lab/StyleLab.js b/public/app/workarea/developer/style-lab/StyleLab.js index 764ce913dc..f8a1cefac8 100644 --- a/public/app/workarea/developer/style-lab/StyleLab.js +++ b/public/app/workarea/developer/style-lab/StyleLab.js @@ -21,7 +21,7 @@ import { FixtureRegistry } from '../shared/FixtureRegistry.js'; import { ExportPresetManager } from '../shared/ExportPresetManager.js'; import { DeveloperStatusReporter, STATUS } from '../shared/DeveloperStatusReporter.js'; -import fixturesManifest from './fixtures.manifest.json'; +import fixturesManifest from './fixtures.manifest.js'; export class StyleLab { constructor({ root, window: win = window } = {}) { diff --git a/public/app/workarea/developer/style-lab/fixtures.manifest.js b/public/app/workarea/developer/style-lab/fixtures.manifest.js new file mode 100644 index 0000000000..05699dd4e9 --- /dev/null +++ b/public/app/workarea/developer/style-lab/fixtures.manifest.js @@ -0,0 +1,47 @@ +/** Auto-kept in sync with the sibling .json for humans/tools that prefer JSON. */ +export default [ + { + "id": "leer-para-aprender", + "label": "Leer para aprender", + "path": "test/fixtures/style-lab/leer-para-aprender.elpx", + "source": "exelearning-style-designer", + "tags": [ + "style-designer", + "cedec", + "style-showcase" + ] + }, + { + "id": "basic-content", + "label": "Basic content", + "path": "test/fixtures/style-lab/basic-content.elpx", + "tags": [ + "basic", + "text", + "headings", + "images" + ] + }, + { + "id": "style-showcase", + "label": "Style showcase", + "path": "test/fixtures/style-lab/style-showcase.elpx", + "tags": [ + "styles", + "idevices", + "boxes", + "export-options" + ] + }, + { + "id": "scorm-score-showcase", + "label": "SCORM score showcase", + "path": "test/fixtures/style-lab/scorm-score-showcase.elpx", + "tags": [ + "scorm", + "score", + "rubric", + "checklist" + ] + } +]; diff --git a/test/e2e/playwright/specs/developer-tools.spec.ts b/test/e2e/playwright/specs/developer-tools.spec.ts index bae6ca9139..0238e361d6 100644 --- a/test/e2e/playwright/specs/developer-tools.spec.ts +++ b/test/e2e/playwright/specs/developer-tools.spec.ts @@ -38,9 +38,12 @@ test.describe('Developer menu visibility', () => { await expect(menu.styleLabLink).toBeAttached(); await expect(menu.ideviceLabLink).toBeAttached(); await expect(menu.restApiLink).toBeAttached(); - await expect(menu.styleLabLink).toHaveAttribute('href', /\/developer\/style-lab$/); - await expect(menu.ideviceLabLink).toHaveAttribute('href', /\/developer\/idevice-lab$/); - await expect(menu.restApiLink).toHaveAttribute('href', /\/api\/v1\/docs$/); + // Static builds use relative hrefs (./developer/...) and may append a + // cache-busting ?v=… query; online builds use absolute /developer/… + // paths. Match the path segment only. + await expect(menu.styleLabLink).toHaveAttribute('href', /(?:^|\/)developer\/style-lab(?:\?|$)/); + await expect(menu.ideviceLabLink).toHaveAttribute('href', /(?:^|\/)developer\/idevice-lab(?:\?|$)/); + await expect(menu.restApiLink).toHaveAttribute('href', /(?:^|\/)api\/v1\/docs(?:\?|$)/); }); }); diff --git a/test/fixtures/style-lab/README.md b/test/fixtures/style-lab/README.md index f4bb4fa614..52f55d5ded 100644 --- a/test/fixtures/style-lab/README.md +++ b/test/fixtures/style-lab/README.md @@ -3,7 +3,7 @@ This directory holds `.elpx` fixtures used by the Developer > Style Lab. The manifest lives in -[`public/app/workarea/developer/style-lab/fixtures.manifest.json`](../../../public/app/workarea/developer/style-lab/fixtures.manifest.json) +[`public/app/workarea/developer/style-lab/fixtures.manifest.json (or fixtures.manifest.js for the browser module)`](../../../public/app/workarea/developer/style-lab/fixtures.manifest.json) and maps fixture IDs (URL-safe slugs) to paths inside this folder. ## Reused assets @@ -13,7 +13,7 @@ its `.elpx` files (notably `leer-para-aprender.elpx`) and example exports as source material for the Style Lab. When importing one, please: 1. Drop it into this folder. -2. Add an entry to `fixtures.manifest.json` with a stable `id` and an honest +2. Add an entry to `fixtures.manifest.json (or fixtures.manifest.js for the browser module)` with a stable `id` and an honest `source` tag (e.g. `"source": "exelearning-style-designer"`). 3. Make sure the file is under a permissive license (the Style Designer ships AGPL-3.0 content).