{"hello":"world"}
+
+
+ Hello
' }; + const after = { content: 'Hello
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 = ` +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 %} ++ 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.
+ ++ Simulator only. The SCORM panel is a developer simulator, not + a full LMS runtime. Always verify SCORM behavior in a real LMS before release. +
++ Developer-only sandbox for testing eXeLearning themes against export targets and + viewport presets. Not for production use. +
+ +