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 a205058225..6d9a0f3a5c 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..ad7b3af765 --- /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.js'; + +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.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/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..f8a1cefac8 --- /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.js'; + +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.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/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 d5adcccac1..8869922c5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,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, initialize as initWebSocket, stop as stopWebSocket } from './websocket/yjs-websocket'; import { webSocketInfoRoutes } from './routes/websocket-info'; @@ -582,6 +583,7 @@ if (registerRootRoutes) { .use(adminTemplatesRoutes) .use(yjsRoutes) .use(apiV1Routes) + .use(developerRoutes) .use(uploadSessionRoutes) .use(createWebSocketRoutes()) .use(webSocketInfoRoutes) @@ -618,6 +620,7 @@ if (routePrefix) { .use(adminTemplatesRoutes) .use(yjsRoutes) .use(apiV1Routes) + .use(developerRoutes) .use(uploadSessionRoutes) .use(createWebSocketRoutes()) .use(webSocketInfoRoutes) 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 84eef46666..66b03c3b7e 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -62,6 +62,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', @@ -926,6 +927,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) @@ -979,6 +981,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..0238e361d6 --- /dev/null +++ b/test/e2e/playwright/specs/developer-tools.spec.ts @@ -0,0 +1,149 @@ +/** + * 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(); + // 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(?:\?|$)/); + }); +}); + +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..52f55d5ded --- /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 (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 + +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 (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). + +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 4257879440..e57fee72be 100644 --- a/views/workarea/menus/menuNavbar.njk +++ b/views/workarea/menus/menuNavbar.njk @@ -116,7 +116,6 @@ + {% if config.isDev %} + + {% endif %}