diff --git a/Classes/Controller/FrontendStudioModuleController.php b/Classes/Controller/FrontendStudioModuleController.php index cd124db3..f504b344 100644 --- a/Classes/Controller/FrontendStudioModuleController.php +++ b/Classes/Controller/FrontendStudioModuleController.php @@ -5,6 +5,7 @@ namespace Andersundsehr\FrontendStudio\Controller; use Andersundsehr\FrontendStudio\Service\ComponentMetadataProvider; +use Andersundsehr\FrontendStudio\Service\ComponentDocumentation; use Andersundsehr\FrontendStudio\Service\ComponentPreviewRenderer; use Andersundsehr\FrontendStudio\Service\FluidUsageSnippetRenderer; use Andersundsehr\FrontendStudio\Service\HtmlSourceHighlighter; @@ -48,11 +49,21 @@ public function __construct( private Typo3Version $typo3Version, private AssetCollector $assetCollector, private FluidTemplateAnalyzer $fluidTemplateAnalyzer, + private ?ComponentDocumentation $documentation = null, ) { } public function handleRequest(ServerRequestInterface $request): ResponseInterface { + $identifier = $request->getQueryParams()['component'] ?? null; + if (is_string($identifier) && $identifier !== '') { + $moduleTemplate = $this->moduleTemplateFactory->create($request); + $moduleTemplate->setTitle($identifier . ' — Docs'); + $moduleTemplate->getDocHeaderComponent()->disable(); + $moduleTemplate->assignMultiple($this->getOverviewAssignments($identifier, $request)); + return $moduleTemplate->renderResponse('FrontendStudio/Overview'); + } + return $this->renderVariantViewResponse($request); } @@ -67,6 +78,46 @@ public function templateAction(ServerRequestInterface $request): ResponseInterfa return new JsonResponse($this->fluidTemplateAnalyzer->analyze($metadata?->template)); } + /** @return array */ + private function getOverviewAssignments(string $identifier, ServerRequestInterface $request): array + { + $component = $this->componentMetadataProvider->getComponentMetadataForIdentifier($identifier); + $variants = $component?->fixture->variants ?? []; + $firstIdentifier = $variants !== [] ? $identifier . ':' . $variants[0]->name : ''; + $assignments = $this->getVariantAssignments($firstIdentifier, $request, false); + $previews = []; + foreach ($variants as $index => $variant) { + $variantIdentifier = $identifier . ':' . $variant->name; + $context = $this->getPreviewContext($variantIdentifier, $request); + $previews[] = [ + 'identifier' => $variantIdentifier, + 'name' => $variant->name, + 'first' => $index === 0, + 'index' => $index, + 'previewUri' => $context['componentPreviewUri'], + 'variantUri' => (string)$this->uriBuilder->buildUriFromRoute('admin_frontendstudio', [ + 'componentVariant' => $variantIdentifier, + 'site' => $context['selectedSiteIdentifier'], + 'language' => $context['selectedLanguageHreflang'], + ]), + ]; + } + + $markdown = ''; + $documentationError = ''; + if ($component !== null && $this->documentation !== null) { + try { + $markdown = $this->documentation->read($identifier)['markdown']; + } catch (Throwable $throwable) { + $documentationError = 'Documentation could not be loaded: ' . $throwable->getMessage(); + } + } + + return [...$assignments, 'overviewComponent' => $component, 'overviewVariants' => $previews, + 'documentationMarkdown' => $markdown, 'documentationError' => $documentationError, + 'showContentNavigationToggle' => $this->typo3Version->getMajorVersion() >= 14]; + } + private function renderVariantViewResponse(ServerRequestInterface $request): ResponseInterface { $moduleTemplate = $this->moduleTemplateFactory->create($request); @@ -89,11 +140,11 @@ private function renderVariantViewResponse(ServerRequestInterface $request): Res /** * @return array */ - private function getVariantAssignments(string $selectedVariantIdentifier, ServerRequestInterface $request): array + private function getVariantAssignments(string $selectedVariantIdentifier, ServerRequestInterface $request, bool $renderSources = true): array { $selectedComponentMetadata = $this->componentMetadataProvider->getComponentMetadataForVariantIdentifier($selectedVariantIdentifier); $previewContext = $this->getPreviewContext($selectedVariantIdentifier, $request); - [$renderedHtmlSource, $renderedHtmlStatus] = $selectedComponentMetadata !== null + [$renderedHtmlSource, $renderedHtmlStatus] = $renderSources && $selectedComponentMetadata !== null ? $this->renderInitialHtmlSource($selectedVariantIdentifier, $request) : ['', '']; @@ -110,8 +161,8 @@ private function getVariantAssignments(string $selectedVariantIdentifier, Server 'variantActiveTab' => $this->getVariantActiveTab($GLOBALS['BE_USER']->uc ?? []), 'renderedHtmlSource' => $renderedHtmlSource, 'renderedHtmlStatus' => $renderedHtmlStatus, - 'fluidTemplateAnalysis' => $this->fluidTemplateAnalyzer->analyze($selectedComponentMetadata?->template), - 'fluidUsageSource' => $this->fluidUsageSnippetRenderer->render($selectedComponentMetadata), + 'fluidTemplateAnalysis' => $this->fluidTemplateAnalyzer->analyze($renderSources ? $selectedComponentMetadata?->template : null), + 'fluidUsageSource' => $renderSources ? $this->fluidUsageSnippetRenderer->render($selectedComponentMetadata) : [], 'componentChangeStreamUri' => Environment::getContext()->isDevelopment() ? (string)$this->uriBuilder->buildUriFromRoute('ajax_frontend_studio_component_change_stream') : '', diff --git a/Classes/Middleware/ComponentPreviewMiddleware.php b/Classes/Middleware/ComponentPreviewMiddleware.php index ab985e8b..bdfd9306 100644 --- a/Classes/Middleware/ComponentPreviewMiddleware.php +++ b/Classes/Middleware/ComponentPreviewMiddleware.php @@ -279,11 +279,8 @@ private function wrapHtml(string $title, string $body): string body { margin: 5px; - background: linear-gradient(45deg, rgba(0, 0, 0, 0.098) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.098) 75%, rgba(0, 0, 0, 0.098) 0), linear-gradient(45deg, rgba(0, 0, 0, 0.098) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.098) 75%, rgba(0, 0, 0, 0.098) 0), white; - background-repeat: repeat, repeat; - background-position: 0 0, 5px 5px; - background-clip: border-box, border-box; - background-size: 10px 10px, 10px 10px; + color-scheme: light dark; + background: repeating-conic-gradient(light-dark(#fff, #ddd) 0% 25%, light-dark(#fff, #fff) 0% 50%) 0 0 / 10px 10px; transition: none; transform: scaleX(1) scaleY(1) scaleZ(1); } diff --git a/Classes/Service/ComponentMetadataProvider.php b/Classes/Service/ComponentMetadataProvider.php index 0753b9df..81ef809f 100644 --- a/Classes/Service/ComponentMetadataProvider.php +++ b/Classes/Service/ComponentMetadataProvider.php @@ -63,7 +63,17 @@ public function getComponentTemplateForIdentifier(string $identifier): ?Componen : null; } + public function getComponentMetadataForIdentifier(string $componentIdentifier): ?ComponentMetadata + { + return $this->getComponentMetadata($componentIdentifier, true); + } + public function getComponentMetadataForVariantIdentifier(string $variantIdentifier): ?ComponentMetadata + { + return $this->getComponentMetadata($variantIdentifier, false); + } + + private function getComponentMetadata(string $variantIdentifier, bool $overview): ?ComponentMetadata { $variantIdentifier = trim($variantIdentifier); if ($variantIdentifier === '') { @@ -71,12 +81,12 @@ public function getComponentMetadataForVariantIdentifier(string $variantIdentifi } $identifierParts = explode(':', $variantIdentifier, 3); - if (count($identifierParts) !== 3) { + if (count($identifierParts) !== ($overview ? 2 : 3)) { return null; } - [$namespace, $componentName, $variantName] = $identifierParts; - if ($namespace === '' || $componentName === '' || $variantName === '') { + [$namespace, $componentName, $variantName] = [...$identifierParts, '']; + if ($namespace === '' || $componentName === '' || (!$overview && $variantName === '')) { return null; } @@ -124,7 +134,7 @@ public function getComponentMetadataForVariantIdentifier(string $variantIdentifi $fixture = $this->getFixtureMetadata($resolverDelegate, $componentName, $variantName); if ($fixture->error !== null) { $errors[] = 'Fixture file could not be loaded: ' . $fixture->error; - } elseif ($fixture->variants !== [] && $fixture->selectedVariant === null) { + } elseif (!$overview && $fixture->variants !== [] && $fixture->selectedVariant === null) { $errors[] = 'Selected variant was not found in the fixture file.'; } diff --git a/README.md b/README.md index 6e13f821..582e410b 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,25 @@ Document what another frontend developer needs to use the component: - Available variants and when to choose each one. - Accessibility requirements, expected interactions, and usage limitations. +### Component Docs overview + +Selecting a component in the tree opens its Docs overview; selecting an individual variant still opens its inspector. +The overview shows the shared component Markdown, then the first variant in fixture declaration order with its existing Controls beneath it. +Outside Production, edit the documentation directly in the overview and save it with **Save**. +The same documentation is available in each variant's **Documentation** tab; Production displays the rendered text. +Other variants appear as separate previews with links to their inspectors. +Live controls affect only the first preview; the remaining examples keep their stored fixture values. +Long documentation starts collapsed with a fade and an Expand documentation button. +Short documentation remains fully visible; expanded documentation can be collapsed again. +Focusing the documentation expands it for editing. +The overview preserves the current site and language context without showing selectors. +Controls provide temporary live edits; fixture save and reset actions remain in the variant inspector. + +A long first preview can be expanded or collapsed. Preview frames resize when content, images, fonts or layout change; +when measurement is unavailable, a scrolling frame and variant link remain available. +Lower previews load lazily. A component without fixtures displays an empty state without creating files. +Production retains temporary live controls while the existing write restrictions continue to apply. + ## Component Fixtures Frontend Studio creates and updates fixture files next to component templates. diff --git a/Resources/Private/Components/Variant/Controls/Controls.fluid.html b/Resources/Private/Components/Variant/Controls/Controls.fluid.html index ba0b72fd..cff1f516 100644 --- a/Resources/Private/Components/Variant/Controls/Controls.fluid.html +++ b/Resources/Private/Components/Variant/Controls/Controls.fluid.html @@ -6,6 +6,7 @@ > + @@ -47,32 +48,34 @@ -
- - - - Production: fixture files are read-only. - - - - - - - -
+ +
+ + + + Production: fixture files are read-only. + + + + + + + +
+
diff --git a/Resources/Private/Templates/FrontendStudio/Overview.fluid.html b/Resources/Private/Templates/FrontendStudio/Overview.fluid.html new file mode 100644 index 00000000..37f136fd --- /dev/null +++ b/Resources/Private/Templates/FrontendStudio/Overview.fluid.html @@ -0,0 +1,102 @@ + + + + + + + + + +
+ + +
+
+ + + +

{overviewComponent.componentName}

+
+

{overviewComponent.componentIdentifier}

+
+

{documentationError}

+ + +
+
+ + + +
+
+
+
{documentationMarkdown}
+
+ +

+ Documentation file: {overviewComponent.template.documentationPath}. + Documentation is shared by all variants of this component. +

+

+
+
+ + + +
+
{documentationMarkdown}
+
+ +
+

No documentation yet.

+
+
+
+ +
  • {error}
+
+ + + +
+

{variant.name}

+ + +
+ +
+

+ + + +
+

The rendered component preview is not available. Configure a preview site and language.

+
+ +
+

Controls

+ +
+
+
+
+
+

No fixture variants yet. Use Create variant in the component tree to add an example.

+
+
+

Component not found

Select a registered component from the tree.

+
+
+ diff --git a/Resources/Public/Css/Backend/component-overview.css b/Resources/Public/Css/Backend/component-overview.css new file mode 100644 index 00000000..f2008dcd --- /dev/null +++ b/Resources/Public/Css/Backend/component-overview.css @@ -0,0 +1,134 @@ +.frontend-studio-overview { + --overview-description-height: 320px; + --overview-border: var(--typo3-component-border-color, #d8dee4); + --overview-muted-surface: var(--typo3-surface-container-low, #f6f8fa); + max-width: 1200px; + margin: 0 auto; + padding: clamp(1.25rem, 4vw, 3rem); + background: var(--typo3-surface-container-lowest, #fff); + overflow-wrap: anywhere; +} + +.frontend-studio-overview-header, +.frontend-studio-overview [data-overview-description] { + max-width: 880px; +} + +.frontend-studio-overview-header { + margin-bottom: 2rem; +} + +.frontend-studio-overview .frontend-studio-documentation { max-width: 880px; padding: 0; } +.frontend-studio-overview [data-doc-rich] { margin: 0; } +.frontend-studio-overview [data-doc-actions] { + top: 0; + background: var(--typo3-surface-container-lowest, #fff); +} + +.frontend-studio-overview-header h1 { + margin: 0; + font-size: clamp(1.75rem, 3vw, 2.25rem); + font-weight: 600; + line-height: 1.25; +} + +.frontend-studio-overview-identifier { + margin: 0.5rem 0 0; + color: var(--typo3-text-color-secondary, #656d76); + font-size: 0.875rem; +} + +.frontend-studio-overview-identifier code { + --typo3-text-color-code: currentColor; + color: inherit; +} + +.frontend-studio-overview [data-overview-markdown] { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.frontend-studio-overview-example { + margin-top: 2.5rem; + padding-top: 2rem; + border-top: 1px solid var(--overview-border); +} + +.frontend-studio-overview-example > h2 { + margin: 0 0 1rem; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; +} + +.frontend-studio-overview h2 [data-overview-variant-link] { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +.frontend-studio-overview-frame { + padding: clamp(0.7rem, 1.5vw, 1rem); + border: 1px solid var(--overview-border); + border-radius: 8px; + background: repeating-conic-gradient(light-dark(#fff, #ddd) 0% 25%, light-dark(#fff, #fff) 0% 50%) 0 0 / 10px 10px; + overflow: hidden; +} + +.frontend-studio-overview-frame.is-first:not(.is-expanded) { + max-height: 480px; + overflow: auto; +} + +.frontend-studio-overview iframe { + display: block; + width: 100%; + height: 240px; + border: 0; + background: transparent; +} + +.frontend-studio-overview [data-overview-frame-status]:empty { + margin: 0; +} + +.frontend-studio-overview-controls { + margin-top: 1rem; + padding: clamp(0.75rem, 2vw, 1.25rem); + border-radius: 8px; + background: var(--overview-muted-surface); +} + +.frontend-studio-overview-controls > h3 { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; +} + +.frontend-studio-overview-controls .frontend-studio-variant-argument-group { + border-color: transparent; + box-shadow: none; +} + +.frontend-studio-overview img { + max-width: 100%; + height: auto; +} + +.frontend-studio-overview [hidden] { + display: none !important; +} + +.frontend-studio-overview [data-overview-description-content] { + display: flow-root; +} + +.frontend-studio-overview [data-overview-description].is-collapsed { + max-height: var(--overview-description-height); + overflow: hidden; + mask-image: linear-gradient(#000 calc(100% - 4rem), transparent); +} + +.frontend-studio-overview-description-toggle { + margin-top: 0.75rem; +} diff --git a/Resources/Public/Css/Backend/variant-view.css b/Resources/Public/Css/Backend/variant-view.css index 0ab0121e..978be3e2 100644 --- a/Resources/Public/Css/Backend/variant-view.css +++ b/Resources/Public/Css/Backend/variant-view.css @@ -50,7 +50,7 @@ min-width: 0; min-height: 0; overflow: auto; - background: var(--frontend-studio-variant-bg); + background: repeating-conic-gradient(light-dark(#fff, #ddd) 0% 25%, light-dark(#fff, #fff) 0% 50%) 0 0 / 10px 10px; } .frontend-studio-variant-preview iframe { diff --git a/Resources/Public/JavaScript/Backend/component-documentation.js b/Resources/Public/JavaScript/Backend/component-documentation.js index 9141a177..5cea3994 100644 --- a/Resources/Public/JavaScript/Backend/component-documentation.js +++ b/Resources/Public/JavaScript/Backend/component-documentation.js @@ -38,11 +38,18 @@ export default class ComponentDocumentation extends VariantFeature { } }, { capture: true }); this.listen(window, 'beforeunload', (event) => { - if (this.dirty) { event.preventDefault(); event.returnValue = ''; } + // Reuse discard approval for this unload unless another navigation guard cancelled it. + if (this.dirty && (!this.approvedNavigation || this.approvedNavigation.defaultPrevented)) { + event.preventDefault(); event.returnValue = ''; + } + this.approvedNavigation = null; }); // TYPO3 navigation replaces the content frame without necessarily unloading the top window. this.navigationGuard = (event) => { - if (this.dirty && !window.confirm('Discard unsaved documentation changes?')) event.preventDefault(); + this.approvedNavigation = null; + if (!this.dirty) return; + if (!window.confirm('Discard unsaved documentation changes?')) event.preventDefault(); + else this.approvedNavigation = event; }; this.listen(top.document, 'frontend-studio:before-navigate', this.navigationGuard); this.listen(view, 'documentation', () => { @@ -56,6 +63,7 @@ export default class ComponentDocumentation extends VariantFeature { get dirty() { return this.loaded && this.markdown !== this.baseline; } changed() { + this.approvedNavigation = null; if (this.saveButton) this.saveButton.disabled = !this.loaded || this.readOnly || this.pending || !this.dirty; if (this.resetButton) this.resetButton.disabled = !this.loaded || this.pending || !this.dirty; if (this.saveState) this.saveState.hidden = !this.dirty; diff --git a/Resources/Public/JavaScript/Backend/component-overview.js b/Resources/Public/JavaScript/Backend/component-overview.js new file mode 100644 index 00000000..80682ebd --- /dev/null +++ b/Resources/Public/JavaScript/Backend/component-overview.js @@ -0,0 +1,192 @@ +import DocumentService from '@typo3/core/document-service.js'; +import VariantFeature from '@andersundsehr/frontend-studio/backend/variant-lifecycle.js'; +import VariantPreview from '@andersundsehr/frontend-studio/backend/variant-preview.js'; +import { getVariantState } from '@andersundsehr/frontend-studio/backend/variant-state.js'; +import { renderMarkdown } from '@andersundsehr/frontend-studio/backend/markdown-renderer.js'; + +export default class ComponentOverview extends VariantFeature { + constructor(root, view) { + super(root, view); + this.frames = Array.from(root.querySelectorAll('[data-overview-frame]')); + this.frameCleanup = new Map(); + this.expand = root.querySelector('[data-overview-expand]'); + const source = root.querySelector('[data-overview-markdown]'); + this.description = root.querySelector('[data-overview-description]'); + this.descriptionContent = root.querySelector('[data-overview-description-content]'); + this.descriptionToggle = root.querySelector('[data-overview-description-toggle]'); + this.descriptionExpanded = false; + if (source) this.descriptionContent.innerHTML = renderMarkdown(source.textContent); + const documentation = root.querySelector('[data-component-documentation]'); + if (documentation) this.documentationReady = this.startDocumentation(documentation); + if (this.descriptionContent && this.descriptionToggle) { + this.descriptionResize = new ResizeObserver(() => this.updateDescription()); + this.descriptionResize.observe(this.descriptionContent); + this.updateDescription(); + this.listen(this.descriptionToggle, 'click', () => { + this.descriptionExpanded = !this.descriptionExpanded; + this.updateDescription(); + }); + // Keyboard navigation must never focus a link hidden beneath the fade. + this.listen(this.description, 'focusin', () => { + this.descriptionExpanded = true; + this.updateDescription(); + }); + this.listen(view, 'suspend', () => this.descriptionResize.disconnect()); + this.listen(view, 'resume', () => { + this.descriptionResize.observe(this.descriptionContent); + this.updateDescription(); + }); + } + if (root.querySelector('[data-frontend-studio-variant-frame]')) { + view.mount('preview', () => new VariantPreview(root, view)); + } + this.listen(view, 'context', () => this.updateContext()); + this.listen(view, 'files', () => this.updateContext(true)); + root.querySelectorAll('[data-overview-variant-link]').forEach((link) => { + this.listen(link, 'click', (event) => { + if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey + || link.hasAttribute('download') || (link.target && link.target !== '_self')) return; + const tree = top.document.querySelector('andersundsehr-frontend-studio-component-tree-container'); + if (!tree) return; + event.preventDefault(); + tree.selectVariant(new URL(link.href, window.location.href).searchParams.get('componentVariant')); + }); + }); + this.listen(view, 'suspend', () => this.clearFrames()); + this.listen(view, 'resume', () => this.frames.forEach((frame) => this.observeFrame(frame))); + this.frames.forEach((frame) => { + this.listen(frame, 'load', () => this.observeFrame(frame)); + this.listen(frame, 'error', () => this.fallback(frame)); + // An eager iframe can finish before the module loads. Do not eagerly load lazy frames. + try { + if (frame.contentDocument?.readyState === 'complete' && frame.contentDocument.URL !== 'about:blank') this.observeFrame(frame); + } catch { this.fallback(frame); } + }); + this.listen(this.expand, 'click', () => { + const expanded = this.expand.getAttribute('aria-expanded') !== 'true'; + this.expand.setAttribute('aria-expanded', String(expanded)); + this.expand.textContent = expanded ? 'Collapse preview' : 'Expand preview'; + this.frames[0]?.closest('[data-overview-frame-container]').classList.toggle('is-expanded', expanded); + }); + } + + async startDocumentation(root) { + try { + const [{ default: Documentation }, { default: Watcher }] = await Promise.all([ + import('@andersundsehr/frontend-studio/backend/component-documentation.js'), + import('@andersundsehr/frontend-studio/backend/variant-file-watcher.js'), + ]); + if (this.destroyed) return; + this.documentation = new Documentation(root, this.view); + this.view.mount('watcher', () => new Watcher(this.root, this.view)); + } catch (error) { + if (!this.destroyed) root.querySelector('[data-doc-status]').textContent = error.message; + } + } + + updateDescription() { + if (this.destroyed) return; + const limit = parseFloat(getComputedStyle(this.description).getPropertyValue('--overview-description-height')); + const long = this.descriptionContent.getBoundingClientRect().height > limit; + const editing = (document.activeElement && this.description.contains(document.activeElement)) + || this.root.querySelector('[data-component-documentation]')?.classList.contains('has-editor-focus'); + if (!long && !editing) this.descriptionExpanded = false; + this.description.classList.toggle('is-collapsed', long && !this.descriptionExpanded); + this.descriptionToggle.hidden = !long; + this.descriptionToggle.setAttribute('aria-expanded', String(this.descriptionExpanded)); + this.descriptionToggle.textContent = this.descriptionExpanded ? 'Collapse documentation' : 'Expand documentation'; + } + + updateContext(force = false) { + const context = this.view.previewUri ? new URL(this.view.previewUri, window.location.href) : null; + this.frames.slice(1).forEach((frame) => { + this.frameCleanup.get(frame)?.(); + if (!context) { frame.src = 'about:blank'; return; } + const url = new URL(context); + url.searchParams.set('componentVariant', frame.dataset.variantIdentifier); + url.searchParams.delete('componentVariantValues'); + url.searchParams.delete('componentVariantSlots'); + if (force || frame.src !== url.toString()) frame.src = url.toString(); + }); + this.root.querySelectorAll('[data-overview-variant-link]').forEach((link) => { + const url = new URL(link.href, window.location.href); + for (const parameter of ['site', 'language']) { + const value = context?.searchParams.get(parameter); + if (value) url.searchParams.set(parameter, value); + else url.searchParams.delete(parameter); + } + link.href = url.toString(); + }); + } + + fallback(frame) { + this.frameCleanup.get(frame)?.(); + frame.style.height = '320px'; + frame.closest('section').querySelector('[data-overview-frame-status]').textContent = 'Automatic preview sizing is unavailable. Scroll the preview or open the variant.'; + } + + observeFrame(frame) { + this.frameCleanup.get(frame)?.(); + let doc; + try { + doc = frame.contentDocument; + if (!doc?.body) { this.fallback(frame); return; } + if (doc.URL === 'about:blank') return; + } catch { this.fallback(frame); return; } + if (!doc) { this.fallback(frame); return; } + // A single background shines through the iframe, keeping the grid aligned + // with the frame's responsive padding without a second pattern origin. + doc.documentElement.style.colorScheme = getComputedStyle(frame).colorScheme; + doc.documentElement.style.background = 'transparent'; + doc.body.style.background = 'transparent'; + let active = true; + let scheduled = null; + const measure = () => { + scheduled = null; + if (!active || this.destroyed || !frame.isConnected) return; + try { + // Remove the old viewport-height floor so previews can shrink as well as grow. + frame.style.height = '1px'; + const height = Math.max(80, doc.documentElement.scrollHeight, doc.body.scrollHeight); + frame.style.height = `${height}px`; + frame.closest('section').querySelector('[data-overview-frame-status]').textContent = ''; + if (frame === this.frames[0] && this.expand) this.expand.hidden = height <= 480; + } catch { this.fallback(frame); } + }; + const schedule = () => { + if (active && scheduled === null) scheduled = window.requestAnimationFrame(measure); + }; + const resize = new ResizeObserver(schedule); + resize.observe(doc.body); + const mutation = new MutationObserver(schedule); + mutation.observe(doc.body, { attributes: true, childList: true, characterData: true, subtree: true }); + doc.addEventListener('load', schedule, true); + window.addEventListener('resize', schedule); + doc.fonts?.ready.then(schedule); + this.frameCleanup.set(frame, () => { + active = false; + resize.disconnect(); mutation.disconnect(); + doc.removeEventListener('load', schedule, true); + window.removeEventListener('resize', schedule); + if (scheduled !== null) window.cancelAnimationFrame(scheduled); + this.frameCleanup.delete(frame); + }); + schedule(); + } + + clearFrames() { + this.frameCleanup.forEach((cleanup) => cleanup()); + } + + destroy() { + this.documentation?.destroy(); + this.descriptionResize?.disconnect(); + this.clearFrames(); + super.destroy(); + } +} + +DocumentService.ready().then(() => document.querySelectorAll('[data-component-overview]').forEach((root) => { + const view = getVariantState(root); + view.mount(root, () => new ComponentOverview(root, view)); +})); diff --git a/Resources/Public/JavaScript/Backend/component-tree-container.js b/Resources/Public/JavaScript/Backend/component-tree-container.js index 2cb14da4..75e749ae 100644 --- a/Resources/Public/JavaScript/Backend/component-tree-container.js +++ b/Resources/Public/JavaScript/Backend/component-tree-container.js @@ -451,7 +451,7 @@ class FrontendStudioComponentTree extends Tree { } async waitForRefreshToFinish() { - while (this.loading === true || this.currentFilterRequest !== null) { + while (this.loading === true || this.currentFilterRequest != null) { await new Promise((resolve) => window.setTimeout(resolve, 50)); } } @@ -555,20 +555,11 @@ class FrontendStudioComponentTreeContainer extends LitElement { return; } - if (propagate !== false) { - ModuleStateStorage.updateWithTreeIdentifier(componentTreeModuleStateType, node.identifier, node.__treeIdentifier); - } else { + if (propagate === false) { return; } - if (node.nodeType !== 'variant') { - const firstChildVariant = this.findFirstChildNodeOfType(node, 'variant'); - - if (firstChildVariant !== null) { - await this.selectNode(firstChildVariant); - return; - } - + if (node.nodeType !== 'variant' && node.nodeType !== 'component') { const firstChildComponent = this.findFirstChildNodeOfType(node, 'component'); if (firstChildComponent !== null) { @@ -578,6 +569,10 @@ class FrontendStudioComponentTreeContainer extends LitElement { return; } + await this.navigateToNode(node); + }; + + async navigateToNode(node, navigationApproved = false) { const moduleMenu = top.TYPO3.ModuleMenu.App; const moduleConfiguration = ModuleUtility.getFromName(moduleMenu.getCurrentModule()); const currentContentUrl = Viewport.ContentContainer.get()?.location?.href || Viewport.ContentContainer.getUrl(); @@ -585,7 +580,7 @@ class FrontendStudioComponentTreeContainer extends LitElement { ? new URL(currentContentUrl, window.location.origin).searchParams : null; const contentParameters = { - componentVariant: node.identifier, + [node.nodeType === 'component' ? 'component' : 'componentVariant']: node.identifier, }; ['site', 'language'].forEach((parameter) => { const value = currentQueryParams?.get(parameter); @@ -594,10 +589,43 @@ class FrontendStudioComponentTreeContainer extends LitElement { } }); const contentUrl = createUrl(moduleConfiguration.link, contentParameters); + contentUrl.searchParams.delete(node.nodeType === 'component' ? 'componentVariant' : 'component'); + + if (!navigationApproved && !top.document.dispatchEvent(new CustomEvent('frontend-studio:before-navigate', { cancelable: true }))) { + const previousNode = this.getNodeFromCurrentContentUrl() + ?? this.getNodeFromStoredState(ModuleStateStorage.current(componentTreeModuleStateType)); + if (previousNode !== null) { + await this.selectNode(previousNode, false); + } else { + this.tree.resetSelectedNodes(); + } + return; + } - if (!top.document.dispatchEvent(new CustomEvent('frontend-studio:before-navigate', { cancelable: true }))) return; + ModuleStateStorage.updateWithTreeIdentifier(componentTreeModuleStateType, node.identifier, node.__treeIdentifier); Viewport.ContentContainer.setUrl(contentUrl); - }; + } + + async selectVariant(identifier) { + if (!this.tree || !identifier) return; + let node = this.tree.nodes.find((candidate) => candidate.nodeType === 'variant' && candidate.identifier === identifier); + if (!node) { + // Confirm before changing the filter; a cancelled Docs link must leave the tree untouched. + if (!top.document.dispatchEvent(new CustomEvent('frontend-studio:before-navigate', { cancelable: true }))) return; + await this.tree.resetFilter(); + // TYPO3 13's resetFilter() can start a reload without returning its promise. + await this.tree.waitForRefreshToFinish(); + const searchInput = this.toolbar?.querySelector(this.toolbar.settings.searchInput); + if (searchInput) searchInput.value = ''; + node = this.tree.nodes.find((candidate) => candidate.nodeType === 'variant' && candidate.identifier === identifier); + if (node) { + await this.selectNode(node, false); + await this.navigateToNode(node, true); + } + return; + } + if (node) await this.selectNode(node); + } async selectNode(node, propagate = true) { await this.tree.expandNodeParents(node); @@ -612,7 +640,8 @@ class FrontendStudioComponentTreeContainer extends LitElement { getNodeFromCurrentContentUrl() { const contentUrl = Viewport.ContentContainer.getUrl(); const componentVariant = contentUrl !== null - ? new URL(contentUrl, window.location.origin).searchParams.get('componentVariant') + ? (new URL(contentUrl, window.location.origin).searchParams.get('component') + || new URL(contentUrl, window.location.origin).searchParams.get('componentVariant')) : null; if (componentVariant === null || componentVariant === '') { diff --git a/Tests/Functional/Fluid/ComponentOverviewTest.php b/Tests/Functional/Fluid/ComponentOverviewTest.php new file mode 100644 index 00000000..9f8cf3b1 --- /dev/null +++ b/Tests/Functional/Fluid/ComponentOverviewTest.php @@ -0,0 +1,259 @@ + 'typo3conf/sites', + ]; + + public function testFixtureOrderDescriptionUniqueFramesAndControls(): void + { + $metadata = $this->get(ComponentMetadataProvider::class)->getComponentMetadataForIdentifier('site:card'); + self::assertNotNull($metadata?->fixture?->absolutePath); + $path = $metadata->fixture->absolutePath; + $original = file_get_contents($path); + $documentation = dirname($path) . '/Card.md'; + try { + file_put_contents($path, "variants:\n Zebra:\n title: First\n Alpha:\n title: Second\n"); + file_put_contents($documentation, "# Documentation\n\n"); + $assignments = $this->assignments('site:card'); + self::assertSame('site:card:Zebra', $assignments['selectedVariantIdentifier']); + self::assertSame(['Zebra', 'Alpha'], array_column($assignments['overviewVariants'], 'name')); + self::assertSame(file_get_contents($documentation), $assignments['documentationMarkdown']); + $html = $this->render($assignments); + self::assertSame(2, substr_count($html, 'data-overview-frame data-variant-identifier')); + foreach (['site-select', 'language-select', 'variant-reset', 'variant-copy', 'variant-save'] as $control) { + self::assertStringNotContainsString('data-frontend-studio-' . $control, $html); + } + + self::assertStringContainsString('class="frontend-studio-markdown" data-overview-description-content', $html); + self::assertStringContainsString('data-component-documentation', $html); + self::assertStringContainsString('data-doc-editor-controls', $html); + self::assertStringContainsString('data-doc-save', $html); + self::assertStringContainsString('data-frontend-studio-variant-value', $html); + self::assertSame(2, substr_count($html, 'data-overview-variant-link')); + self::assertStringNotContainsString('Open Zebra variant', $html); + self::assertStringNotContainsString('Open Alpha variant', $html); + self::assertStringContainsString('componentVariant=site%3Acard%3AAlpha', $html); + self::assertStringContainsString('site=preview', $html); + self::assertStringContainsString('language=de', $html); + self::assertStringContainsString('loading="lazy"', $html); + self::assertStringContainsString('<script>', $html); + self::assertStringNotContainsString('', $html); + preg_match_all('/\\bid="([^"]+)"/', $html, $matches); + self::assertSame($matches[1], array_values(array_unique($matches[1]))); + } finally { + file_put_contents($path, $original); + unlink($documentation); + } + } + + public function testEmptySingleAndUnknownComponentsHaveClearStates(): void + { + $metadata = $this->get(ComponentMetadataProvider::class)->getComponentMetadataForIdentifier('site:card'); + self::assertNotNull($metadata?->fixture?->absolutePath); + $path = $metadata->fixture->absolutePath; + $original = file_get_contents($path); + try { + file_put_contents($path, "variants:\n Default:\n title: Single\n"); + $single = $this->assignments('site:card'); + self::assertCount(1, $single['overviewVariants']); + file_put_contents($path, "variants: {}\n"); + $empty = $this->assignments('site:card'); + self::assertSame('', $empty['selectedVariantIdentifier']); + self::assertSame([], $empty['overviewVariants']); + self::assertStringContainsString('No fixture variants yet', $this->render($empty)); + self::assertStringContainsString('data-component-documentation', $this->render($empty)); + self::assertStringContainsString('data-doc-rich', $this->render($empty)); + self::assertStringContainsString('Component not found', $this->render($this->assignments('site:unknown'))); + self::assertSame("variants: {}\n", file_get_contents($path)); + } finally { + file_put_contents($path, $original); + } + } + + public function testProductionOverviewRendersDocumentationWithoutEditingControls(): void + { + $assignments = $this->assignments('site:card'); + $component = $assignments['overviewComponent']; + self::assertInstanceOf(ComponentMetadata::class, $component); + $assignments['overviewComponent'] = new ComponentMetadata(...array_replace(get_object_vars($component), ['readOnly' => true])); + $assignments['documentationMarkdown'] = '# Production documentation'; + $html = $this->render($assignments); + self::assertStringContainsString('data-overview-markdown', $html); + self::assertStringContainsString('# Production documentation', $html); + self::assertStringNotContainsString('data-component-documentation', $html); + self::assertStringNotContainsString('data-doc-save', $html); + self::assertStringNotContainsString('data-doc-editor-controls', $html); + $assignments['documentationMarkdown'] = ''; + self::assertStringContainsString('No documentation yet', $this->render($assignments)); + } + + public function testAuthenticatedModuleRoutesKeepOverviewAndVariantViewsDistinct(): void + { + $this->importCSVDataSet(__DIR__ . '/../Fixtures/BackendUser.csv'); + $user = $this->setUpBackendUser(1); + $cookie = $user->getSession()->getJwt(new CookieScope('preview.test', true, '/')); + foreach (['component' => 'site:card', 'componentVariant' => 'site:card:Default'] as $parameter => $identifier) { + $uri = (string)$this->get(UriBuilder::class)->buildUriFromRoute('admin_frontendstudio', [$parameter => $identifier]); + parse_str((string)parse_url($uri, PHP_URL_QUERY), $query); + $request = new ServerRequest('https://preview.test' . $uri, 'GET', null, [], [ + 'HTTP_HOST' => 'preview.test', 'HTTPS' => 'on', 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_USER_AGENT' => 'TYPO3 Functional Test Request', 'SCRIPT_NAME' => '/index.php', + 'SCRIPT_FILENAME' => Environment::getPublicPath() . '/index.php', + 'REQUEST_URI' => $uri, 'REQUEST_METHOD' => 'GET', + ])->withQueryParams($query)->withCookieParams([BackendUserAuthentication::getCookieName() => $cookie]); + $GLOBALS['TYPO3_REQUEST'] = $request; + $response = $this->get(BackendApplication::class)->handle($request); + $html = (string)$response->getBody(); + self::assertSame(200, $response->getStatusCode(), $html); + if ($parameter === 'component') { + self::assertStringContainsString('data-component-overview', $html); + self::assertStringContainsString('/Css/Backend/component-markdown.css', $html); + self::assertStringNotContainsString('data-frontend-studio-variant-sidebar', $html); + } else { + self::assertStringContainsString('data-frontend-studio-variant-sidebar', $html); + self::assertStringNotContainsString('data-component-overview', $html); + } + } + } + + /** @return iterable */ + public static function liveReloadContexts(): iterable + { + yield 'Development' => ['Development', true]; + yield 'Production' => ['Production', false]; + } + + #[DataProvider('liveReloadContexts')] + public function testOverviewAndVariantExposeTheAssignedLiveReloadUri(string $context, bool $enabled): void + { + $property = new ReflectionProperty(Environment::class, 'context'); + $original = Environment::getContext(); + try { + $property->setValue(null, new ApplicationContext($context)); + $expectedUri = $enabled + ? (string)$this->get(UriBuilder::class)->buildUriFromRoute('ajax_frontend_studio_component_change_stream') + : ''; + $variantAssignments = new ReflectionMethod(FrontendStudioModuleController::class, 'getVariantAssignments')->invoke( + $this->get(FrontendStudioModuleController::class), + 'site:card:Default', + new ServerRequest('https://example.test/typo3/module/admin/frontend-studio'), + false, + ); + foreach (['Overview' => $this->assignments('site:card'), 'Variant' => $variantAssignments] as $template => $assignments) { + self::assertSame($expectedUri, $assignments['componentChangeStreamUri']); + self::assertStringContainsString( + 'data-component-change-stream-uri="' . htmlspecialchars($expectedUri) . '"', + $this->render($assignments, $template), + $template, + ); + } + } finally { + $property->setValue(null, $original); + } + } + + /** @return iterable */ + public static function navigationToggleVersions(): iterable + { + yield 'TYPO3 13' => [13, false]; + yield 'TYPO3 14' => [14, true]; + } + + #[DataProvider('navigationToggleVersions')] + public function testOverviewOnlyLoadsNavigationToggleOnSupportedVersions(int $majorVersion, bool $supported): void + { + $assignments = $this->assignments('site:card', $majorVersion); + self::assertSame($supported, $assignments['showContentNavigationToggle']); + $html = $this->render($assignments); + $modules = $this->get(AssetCollector::class)->getJavaScriptModules(); + $module = '@typo3/backend/viewport/content-navigation-toggle.js'; + if ($supported) { + self::assertContains($module, $modules); + self::assertStringContainsString(' */ + private function assignments(string $identifier, ?int $majorVersion = null): array + { + $controller = $this->get(FrontendStudioModuleController::class); + if ($majorVersion !== null) { + $version = $this->createStub(Typo3Version::class); + $version->method('getMajorVersion')->willReturn($majorVersion); + $controller = new FrontendStudioModuleController( + $this->get(ModuleTemplateFactory::class), + $this->get(ComponentMetadataProvider::class), + $this->get(ComponentPreviewRenderer::class), + $this->get(FluidUsageSnippetRenderer::class), + $this->get(HtmlSourceHighlighter::class), + $this->get(UriBuilder::class), + $this->get(SiteFinder::class), + $this->get(PreviewContextResolver::class), + $version, + $this->get(AssetCollector::class), + $this->get(FluidTemplateAnalyzer::class), + $this->get(ComponentDocumentation::class), + ); + } + + $method = new ReflectionMethod(FrontendStudioModuleController::class, 'getOverviewAssignments'); + return $method->invoke( + $controller, + $identifier, + new ServerRequest('https://example.test/typo3/module/admin/frontend-studio')->withQueryParams(['site' => 'preview', 'language' => 'de']), + ); + } + + /** @param array $assignments */ + private function render(array $assignments, string $template = 'Overview'): string + { + $context = $this->get(RenderingContextFactory::class)->create([ + 'templateRootPaths' => [__DIR__ . '/../../../Resources/Private/Templates'], + ], new ServerRequest('https://example.test/typo3/module/admin/frontend-studio')); + $view = new TemplateView($context); + $view->assignMultiple($assignments); + return (string)$view->render('FrontendStudio/' . $template); + } +} diff --git a/Tests/Unit/Service/ComponentMetadataProviderTest.php b/Tests/Unit/Service/ComponentMetadataProviderTest.php index e954a841..9a81585a 100644 --- a/Tests/Unit/Service/ComponentMetadataProviderTest.php +++ b/Tests/Unit/Service/ComponentMetadataProviderTest.php @@ -69,6 +69,24 @@ protected function tearDown(): void @rmdir(dirname($this->templatePath)); } + public function testComponentOverviewResolvesWithoutSelectingOrInventingAVariant(): void + { + $fixturePath = dirname($this->templatePath) . '/Card.fixture.yaml'; + file_put_contents($fixturePath, "variants:\n Zebra: []\n Alpha: []\n"); + $provider = $this->createProvider([]); + $metadata = $provider->getComponentMetadataForIdentifier('test:Card'); + self::assertNotNull($metadata); + self::assertSame('', $metadata->variantName); + self::assertNull($metadata->fixture?->selectedVariant); + self::assertSame(['Zebra', 'Alpha'], array_column($metadata->fixture->variants ?? [], 'name')); + self::assertNotContains('Selected variant was not found in the fixture file.', $metadata->errors); + self::assertNull($provider->getComponentMetadataForVariantIdentifier('test:Card')); + self::assertNotNull($provider->getComponentMetadataForVariantIdentifier('test:Card:Alpha')?->fixture?->selectedVariant); + unlink($fixturePath); + self::assertSame([], $provider->getComponentMetadataForIdentifier('test:Card')?->fixture?->variants); + self::assertFileDoesNotExist($fixturePath); + } + #[DataProvider('fixtureValuesDataProvider')] public function testInitialUsageMatchesTypedControlUpdates(string $type, string $fixtureValue, int|float|string $expected, string $inlineValue): void { diff --git a/Tests/browser/README.md b/Tests/browser/README.md index 328e23be..637fcb90 100644 --- a/Tests/browser/README.md +++ b/Tests/browser/README.md @@ -22,6 +22,14 @@ Extension tests that launch Playwright also belong in `Tests/browser/`. Existing tests in `Playwright/src/` stay in place, including legacy extension regressions; add new extension regressions in `Tests/browser/`. +`componentTreeFiltering.mjs` runs Chromium against the unmodified TYPO3 13.4.35 and 14.3.7 +Tree and TreeToolbar, including each release's Lit, Ajax, debounce and storage implementations. +It downloads pinned public JavaScript from `TYPO3-CMS/backend` and `TYPO3-CMS/core` on GitHub +on the first run and caches it in `var/.cache/typo3-tree/`. +The initial run needs network access to `raw.githubusercontent.com`; cached runs work offline. +Only unrelated backend chrome and HTTP endpoint responses are stubbed. +The tests click real Overview links in a content iframe and edit the actual documentation editor. + The consumer package exports TypeScript directly and has no separate compilation step. Keep its manifest, TypeScript configuration and source unchanged when working on extension UI or contributor tooling. Do not generate `Playwright/dist/` or recreate `Playwright/browser/`. diff --git a/Tests/browser/componentOverview.mjs b/Tests/browser/componentOverview.mjs new file mode 100644 index 00000000..1028afe3 --- /dev/null +++ b/Tests/browser/componentOverview.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test, { before, after } from 'node:test'; + +import { chromium } from '@playwright/test'; + +let browser; +before(async () => { browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); }); +after(async () => { await browser?.close(); }); +const resources = new URL('../../Resources/Public/', import.meta.url); +const css = (await Promise.all(['variant-view.css', 'component-markdown.css', 'component-documentation.css', 'component-overview.css'].map(name => readFile(new URL(`Css/Backend/${name}`, resources), 'utf8')))).join('\n'); +async function setup(t, { markdown = '# Card\n\nOriginal paragraph\n', production = false, width = 1100 } = {}) { + const page = await browser.newPage({ viewport: { width, height: 850 } }); + const errors = []; + const requests = []; + const assets = []; + const server = { markdown, revision: 'r1', production }; + page.on('pageerror', error => errors.push(error.message)); + t.after(async () => { assert.deepEqual(errors, []); await page.close(); }); + await page.route('https://studio.test/**', async route => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === '/docs') { + const body = Object.fromEntries(new URLSearchParams(request.postData() || '')); + requests.push({ method: request.method(), body }); + if (request.method() === 'POST') { + if (body.revision !== server.revision) return route.fulfill({ status: 409, json: { message: 'Documentation changed on disk.' } }); + server.markdown = body.markdown; + server.revision = 'r' + (Number(server.revision.slice(1)) + 1); + } + return route.fulfill({ json: { markdown: server.markdown, revision: server.revision, readOnly: production } }); + } + if (path === '/document-service.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default { ready: () => Promise.resolve() };' }); + if (path === '/notification.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default { success() {}, error() {} };' }); + if (path === '/Backend/variant-preview.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default class {}' }); + const file = path.startsWith('/Backend/') ? `JavaScript${path}` : path.startsWith('/Contrib/') ? path.slice(1) : null; + if (file) { + assets.push(file); + return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL(file, resources), 'utf8') }); + } + const content = '
'; + const doc = production ? content : `
${content}

`; + await route.fulfill({ contentType: 'text/html', body: `

Card

${doc}
` }); + }); + await page.goto('https://studio.test/'); + await page.evaluate(async source => { + document.querySelector('[data-overview-markdown]').textContent = source; + const { default: Overview } = await import('/Backend/component-overview.js'); + const { getVariantState } = await import('/Backend/variant-state.js'); + window.overview = getVariantState(document.querySelector('main')).features.get(document.querySelector('main')); + }, markdown); + if (!production) await page.getByRole('textbox', { name: 'Component documentation rich text' }).waitFor(); + return { page, server, requests, assets }; +} + +test('the Docs overview uses the persistent editor with Save, Reset and toolbar save shortcuts', async t => { + const { page, server, requests } = await setup(t); + await page.evaluate(() => { window.node = document.querySelector('.tiptap'); window.fixtureSaves = 0; document.addEventListener('keydown', e => { if (e.ctrlKey && e.key === 's') window.fixtureSaves++; }); }); + await page.locator('.tiptap p').click(); + assert.equal(await page.evaluate(() => window.node === document.querySelector('.tiptap')), true); + await page.keyboard.press('End'); + await page.keyboard.type(' edited'); + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + assert.equal(await page.locator('.tiptap p').textContent(), 'Original paragraph'); + await page.locator('.tiptap p').click(); + await page.keyboard.press('End'); + await page.keyboard.type(' saved'); + await page.getByRole('button', { name: 'Text style' }).focus(); + await page.keyboard.press('ControlOrMeta+s'); + await page.waitForFunction(() => document.querySelector('[data-doc-status]').textContent === 'Documentation saved.'); + assert.ok(server.markdown.includes('Original paragraph saved')); + assert.equal(requests.filter(r => r.method === 'POST').length, 1); + assert.equal(await page.evaluate(() => window.fixtureSaves), 0); + assert.equal(await page.locator('[data-doc-save]').isDisabled(), true); +}); + +test('empty overview documentation can be created without a fixture or an initial write', async t => { + const { page, server, requests } = await setup(t, { markdown: '', width: 360 }); + assert.equal(requests.filter(r => r.method === 'POST').length, 0); + await page.locator('.tiptap').click(); + await page.keyboard.type('# New documentation'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('[data-doc-status]').textContent === 'Documentation saved.'); + assert.equal(server.markdown, '# New documentation'); +}); + +test('Production uses rendered documentation without loading the editor or requesting editable documentation', async t => { + const { page, assets, requests } = await setup(t, { production: true }); + assert.equal(await page.locator('[data-overview-description] h1').textContent(), 'Card'); + assert.equal(await page.locator('[contenteditable], [data-doc-actions]').count(), 0); + assert.equal(assets.some(file => file.includes('inline-documentation-editor') || file.includes('markdown-editor') || file.includes('component-documentation')), false); + assert.equal(requests.length, 0); +}); + +test('focusing long Docs expands it and external documentation changes preserve unsaved edits', async t => { + const { page, server } = await setup(t, { markdown: '# Long documentation\n\n' + 'A paragraph.\n\n'.repeat(20) }); + await page.locator('[data-overview-description].is-collapsed').waitFor(); + await page.locator('.tiptap p').first().click(); + assert.equal(await page.locator('[data-overview-description]').evaluate(node => node.classList.contains('is-collapsed')), false); + await page.keyboard.press('End'); + await page.keyboard.type(' local'); + server.markdown = '# Changed on disk\n'; server.revision = 'r2'; + await page.evaluate(() => top.document.dispatchEvent(new CustomEvent('frontend-studio:component-documentation-changed', { detail: { componentIdentifiers: ['site:card'] } }))); + assert.ok((await page.locator('.tiptap').textContent()).includes(' local')); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('[data-doc-status]').textContent === 'Documentation changed on disk.'); + assert.ok((await page.locator('.tiptap').textContent()).includes(' local')); +}); + +test('short documentation stays expanded when it grows beyond the collapse limit during editing', async t => { + const { page } = await setup(t, { markdown: 'Short\n' }); + await page.locator('.tiptap').click(); + await page.keyboard.press('End'); + for (let i = 0; i < 12; i++) { await page.keyboard.press('Enter'); await page.keyboard.type('More documentation'); } + await page.waitForFunction(() => document.querySelector('[data-overview-description-content]').getBoundingClientRect().height > 320); + assert.equal(await page.locator('[data-overview-description]').evaluate(node => node.classList.contains('is-collapsed')), false); + await page.getByRole('button', { name: 'Collapse documentation' }).click(); + assert.equal(await page.locator('[data-overview-description]').evaluate(node => node.classList.contains('is-collapsed')), true); +}); + +test('sticky Docs actions have an opaque theme background and sit at the viewport top', async t => { + for (const options of [{ theme: 'light', width: 1100, surface: '#fff' }, { theme: 'dark', width: 360, surface: '#131315' }]) { + await t.test(options.theme, async t => { + const { page } = await setup(t, { markdown: '# Documentation\n\n' + 'A paragraph.\n\n'.repeat(30), width: options.width }); + await page.evaluate(({ theme, surface }) => { + document.documentElement.style.colorScheme = theme; + document.documentElement.style.setProperty('--typo3-surface-container-lowest', surface); + document.documentElement.style.setProperty('--module-docheader-bar-height', '52px'); + document.documentElement.style.setProperty('--module-docheader-border-width', '1px'); + document.body.style.background = surface; + }, options); + await page.locator('.tiptap p').first().click(); + await page.evaluate(() => window.scrollTo(0, 400)); + await page.waitForFunction(() => window.scrollY > 100); + const row = page.locator('[data-doc-actions]'); + assert.equal(await row.evaluate(node => getComputedStyle(node).backgroundColor), await page.locator('main').evaluate(node => getComputedStyle(node).backgroundColor)); + const bounds = await row.boundingBox(); + assert.ok(Math.abs(bounds.y) < 1, `Sticky row starts at ${bounds.y}px`); + assert.equal(await row.evaluate(node => { + const bounds = node.getBoundingClientRect(); + return node.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + 1)); + }), true); + }); + } +}); diff --git a/Tests/browser/componentOverview.test.ts b/Tests/browser/componentOverview.test.ts new file mode 100644 index 00000000..091ce1f0 --- /dev/null +++ b/Tests/browser/componentOverview.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createContext, SourceTextModule, SyntheticModule } from 'node:vm'; +import test from 'node:test'; + +import { DOMParser } from 'linkedom'; + +async function setup(height = 600, documentation = true) { + const document = new DOMParser().parseFromString(`
${documentation ? ` +
Documentation
+ ` : ''}
`, 'text/html'); + const root = document.querySelector('main'); + const description = root.querySelector('[data-overview-description]'); + const content = root.querySelector('[data-overview-description-content]'); + const button = root.querySelector('button'); + if (content) content.getBoundingClientRect = () => ({ height }); + const observers: any[] = []; + const context = createContext({ + AbortController, Event, EventTarget, + document: { querySelectorAll: () => [] }, + getComputedStyle: () => ({ getPropertyValue: () => '320px' }), + ResizeObserver: class { + observed = false; + callback: () => void; + constructor(callback: () => void) { this.callback = callback; observers.push(this); } + observe() { this.observed = true; } + disconnect() { this.observed = false; } + }, + }); + const lifecycle = new SourceTextModule(await readFile(new URL('../../Resources/Public/JavaScript/Backend/variant-lifecycle.js', import.meta.url), 'utf8'), { context }); + await lifecycle.link(() => { throw new Error('Unexpected dependency'); }); + await lifecycle.evaluate(); + const dependencies: Record> = { + '@typo3/core/document-service.js': { default: { ready: () => Promise.resolve() } }, + '@andersundsehr/frontend-studio/backend/variant-preview.js': { default: class {} }, + '@andersundsehr/frontend-studio/backend/variant-state.js': { getVariantState() { throw new Error('Unexpected mount'); } }, + '@andersundsehr/frontend-studio/backend/markdown-renderer.js': { renderMarkdown: (source: string) => `

${source}

` }, + }; + const module = new SourceTextModule(await readFile(new URL('../../Resources/Public/JavaScript/Backend/component-overview.js', import.meta.url), 'utf8'), { context }); + await module.link(async (specifier) => { + if (specifier.endsWith('/variant-lifecycle.js')) return lifecycle; + const values = dependencies[specifier]; + assert.ok(values, specifier); + const dependency = new SyntheticModule(Object.keys(values), function () { + for (const [name, value] of Object.entries(values)) this.setExport(name, value); + }, { context }); + await dependency.link(() => { throw new Error('Unexpected dependency'); }); + return dependency; + }); + await module.evaluate(); + const Overview = (module.namespace as any).default; + const view = new EventTarget(); + const overview = new Overview(root, view); + return { + overview, view, description, content, button, observer: observers[0], + click: () => button.dispatchEvent(new document.defaultView.Event('click')), + focus: () => description.dispatchEvent(new document.defaultView.Event('focusin')), + resize: (next: number) => { height = next; observers[0].callback(); }, + }; +} + +test('long documentation starts faded and collapsed, and its button expands and collapses it', async () => { + const ui = await setup(); + assert.equal(ui.description.classList.contains('is-collapsed'), true); + assert.equal(ui.button.hidden, false); + assert.equal(ui.button.getAttribute('aria-expanded'), 'false'); + assert.equal(ui.content.innerHTML, '

Documentation

'); + ui.click(); + assert.equal(ui.description.classList.contains('is-collapsed'), false); + assert.equal(ui.button.getAttribute('aria-expanded'), 'true'); + assert.equal(ui.button.textContent, 'Collapse documentation'); + ui.click(); + assert.equal(ui.description.classList.contains('is-collapsed'), true); + assert.equal(ui.button.textContent, 'Expand documentation'); + ui.overview.destroy(); +}); + +test('short documentation has no toggle and resizing or delayed content can change whether it is long', async () => { + const ui = await setup(320); + assert.equal(ui.description.classList.contains('is-collapsed'), false); + assert.equal(ui.button.hidden, true); + ui.resize(321); + assert.equal(ui.description.classList.contains('is-collapsed'), true); + assert.equal(ui.button.hidden, false); + ui.click(); + ui.resize(800); + assert.equal(ui.button.getAttribute('aria-expanded'), 'true'); + ui.resize(100); + assert.equal(ui.button.hidden, true); + assert.equal(ui.button.getAttribute('aria-expanded'), 'false'); + ui.resize(600); + assert.equal(ui.description.classList.contains('is-collapsed'), true); + ui.overview.destroy(); +}); + +test('keyboard focus reveals documentation links and observers suspend, resume and disconnect on destruction', async () => { + const ui = await setup(); + ui.focus(); + assert.equal(ui.description.classList.contains('is-collapsed'), false); + assert.equal(ui.button.getAttribute('aria-expanded'), 'true'); + ui.view.dispatchEvent(new Event('suspend')); + assert.equal(ui.observer.observed, false); + ui.view.dispatchEvent(new Event('resume')); + assert.equal(ui.observer.observed, true); + assert.equal(ui.button.getAttribute('aria-expanded'), 'true'); + ui.overview.destroy(); + assert.equal(ui.observer.observed, false); + ui.resize(100); + assert.equal(ui.button.getAttribute('aria-expanded'), 'true'); +}); + +test('an overview without documentation does not create a description observer', async () => { + const ui = await setup(0, false); + assert.equal(ui.observer, undefined); + ui.overview.destroy(); +}); diff --git a/Tests/browser/componentOverviewIntegration.mjs b/Tests/browser/componentOverviewIntegration.mjs new file mode 100644 index 00000000..30835c13 --- /dev/null +++ b/Tests/browser/componentOverviewIntegration.mjs @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test, { before, after } from 'node:test'; + +import { chromium } from '@playwright/test'; +const resources = new URL('../../Resources/Public/', import.meta.url); +const css = (await Promise.all(['variant-view.css', 'component-markdown.css', 'component-documentation.css', 'component-overview.css'].map(name => readFile(new URL(`Css/Backend/${name}`, resources), 'utf8')))).join('\n'); +const imports = { + '@andersundsehr/frontend-studio/backend/': '/Backend/', + '@andersundsehr/frontend-studio/vendor/inline-documentation-editor': '/Contrib/inline-documentation-editor.js', + '@andersundsehr/frontend-studio/vendor/markdown-converter': '/Contrib/markdown-converter.js', + '@andersundsehr/frontend-studio/vendor/code-highlighting': '/Contrib/code-highlighting.js', + '@typo3/core/document-service.js': '/document-service.js', + '@typo3/backend/notification.js': '/notification.js', + '@typo3/backend/module.js': '/module.js', + '@typo3/backend/viewport.js': '/viewport.js', + '@typo3/backend/storage/module-state-storage.js': '/module-state.js', + '@typo3/backend/tree/tree.js': '/tree.js', + '@typo3/backend/tree/tree-toolbar.js': '/tree-toolbar.js', + '@typo3/backend/': '/stub/', + '@typo3/core/ajax/ajax-request.js': '/stub/ajax.js', + lit: '/lit.js', +}; +const head = ``; +let browser; +before(async () => { browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); }); +after(async () => { await browser?.close(); }); + +async function setup(t, version = 14) { + const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); + page.setDefaultTimeout(10000); + const errors = []; + const dialogs = []; + const previews = []; + const documents = []; + const server = { markdown: '# Documentation\n\nOriginal paragraph\n', revision: 'r1', fixture: 'Original fixture' }; + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', async dialog => { dialogs.push(dialog.type()); await dialog.dismiss(); }); + t.after(async () => { await page.close(); assert.deepEqual(errors, []); }); + await page.route('https://studio.test/**', async route => { + const url = new URL(route.request().url()); + const path = url.pathname; + if (path === '/docs') { + documents.push(route.request().method()); + return route.fulfill({ json: { ...server, readOnly: false } }); + } + if (path === '/preview') { + previews.push(url.searchParams.get('componentVariant')); + return route.fulfill({ contentType: 'text/html', body: '

' + server.fixture + '

' }); + } + const file = path.startsWith('/Backend/') ? `JavaScript${path}` : path.startsWith('/Contrib/') ? path.slice(1) : null; + if (file) return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL(file, resources), 'utf8') }); + const modules = { + '/document-service.js': 'export default { ready: () => Promise.resolve() };', + '/notification.js': 'export default { success() {}, error() {} };', + '/lit.js': 'export class LitElement extends HTMLElement { connectedCallback() {} disconnectedCallback() {} } export const html = () => "";', + '/tree.js': 'export class Tree extends HTMLElement {}', + '/tree-toolbar.js': 'export class TreeToolbar extends HTMLElement {}', + '/module.js': 'export const ModuleUtility = { getFromName: () => ({ link: "/module?token=module" }) };', + '/viewport.js': 'export default window.viewport;', + '/module-state.js': `export const ModuleStateStorage = { + current: () => window.stored, + updateWithTreeIdentifier: (type, identifier, treeIdentifier) => { + window.order.push('persist'); window.persisted.push({ type, identifier, treeIdentifier }); + window.stored = { identifier, treeIdentifier }; + } + };`, + }; + if (path in modules) return route.fulfill({ contentType: 'text/javascript', body: modules[path] }); + if (path.startsWith('/stub/')) return route.fulfill({ contentType: 'text/javascript', body: 'export default {}; export const TreeNodePositionEnum = {}; export const SeverityEnum = {};' }); + if (path === '/backend') { + return route.fulfill({ contentType: 'text/html', body: `${head}` }); + } + const overview = url.searchParams.has('component'); + const identifier = url.searchParams.get('componentVariant') || 'site:card:Default'; + const preview = '/preview?componentVariant=' + encodeURIComponent(identifier) + '&site=preview&language=de-DE'; + const doc = `
+
+
+

+
`; + const previewFrame = (name, first) => `

`; + const links = overview ? ['Default', 'Mobile:Dark'].map(name => `

${name}

`).join('') : ''; + return route.fulfill({ contentType: 'text/html', body: `${head}
+ ${doc}${links}${previewFrame(overview ? 'Default' : identifier.split(':').slice(2).join(':'), true)}${overview ? previewFrame('Mobile:Dark', false) : ''} +
` }); + }); + await page.goto('https://studio.test/backend'); + const frame = () => page.frames().find(frame => frame.url().includes('/module?')); + const ready = async (overview = true) => { + await page.waitForFunction(overview => { + const content = document.querySelector('#content')?.contentWindow; + return content?.location.href.includes(overview ? 'component=' : 'componentVariant=') && content.ready && window.streams.length === 1; + }, overview); + await page.frameLocator('#content').getByRole('textbox', { name: 'Component documentation rich text' }).waitFor(); + }; + await ready(); + const emit = payload => page.evaluate(payload => window.streams[0].dispatchEvent(new MessageEvent('component-files-changed', { data: JSON.stringify(payload) })), payload); + return { page, frame, ready, emit, server, previews, documents, dialogs }; +} + +for (const version of [13, 14]) { + test(`Overview variant links use the tree guard and preserve context on TYPO3 ${version}`, async t => { + const ui = await setup(t, version); + const { page, dialogs } = ui; + await ui.frame().locator('.tiptap').click(); + await ui.frame().getByRole('button', { name: 'Edit Markdown source' }).click(); + await ui.frame().locator('textarea').fill('# Unsaved documentation\n'); + const link = ui.frame().getByRole('link', { name: 'Mobile:Dark' }); + await link.click(); + assert.deepEqual(dialogs, ['confirm']); + assert.equal(await ui.frame().locator('textarea').inputValue(), '# Unsaved documentation\n'); + assert.deepEqual(await page.evaluate(() => ({ order, stored, selected: container.getSelectedNode().identifier, navigations })), { + order: ['confirm'], stored: { identifier: 'site:card', treeIdentifier: 'site_site:card' }, selected: 'site:card', navigations: [], + }); + page.removeAllListeners('dialog'); + page.on('dialog', async dialog => { dialogs.push(dialog.type()); await dialog.accept(); }); + // Filtered navigation uses the real TYPO3 Tree in componentTreeFiltering.mjs. + await link.locator('span').click(); + await ui.ready(false); + assert.deepEqual(dialogs, ['confirm', 'confirm'], 'discard confirmation must not be followed by a native unload dialog'); + const state = await page.evaluate(() => ({ order, stored, selected: container.getSelectedNode().identifier, navigations, persisted, streams: streams.map(stream => ({ uri: stream.uri, closed: stream.closed })) })); + assert.deepEqual(state.order, ['confirm', 'confirm', 'persist', 'navigate']); + assert.equal(state.selected, 'site:card:Mobile:Dark'); + assert.deepEqual(state.stored, { identifier: state.selected, treeIdentifier: 'site_site:card_site:card:Mobile:Dark' }); + assert.equal(state.persisted.length, 1); + assert.equal(state.navigations.length, 1); + assert.equal(await page.evaluate(() => treeLoads), 0); + const url = new URL(state.navigations[0]); + assert.equal(url.searchParams.get('componentVariant'), state.selected); + assert.equal(url.searchParams.has('component'), false); + assert.equal(url.searchParams.get('site'), 'preview'); + assert.equal(url.searchParams.get('language'), 'de-DE'); + assert.equal(url.searchParams.get('token'), 'module'); + assert.deepEqual(state.streams, [{ uri: '/changes?token=stream', closed: false }]); + }); +} + +for (const type of ['Overview', 'Variant Inspector']) { + test(`${type} receives fixture and documentation SSE updates without losing unsaved docs`, async t => { + const ui = await setup(t); + const { page, server, documents } = ui; + if (type === 'Variant Inspector') { + await ui.frame().getByRole('link', { name: 'Default' }).click(); + await ui.ready(false); + } + const variants = type === 'Overview' ? ['Default', 'Mobile:Dark'] : ['Default']; + for (const name of variants) await ui.frame().frameLocator(`iframe[data-variant-identifier="site:card:${name}"]`).getByText('Original fixture').waitFor(); + server.markdown = '# External documentation\n'; server.revision = 'r2'; + await ui.emit({ componentIdentifiers: [], documentationComponentIdentifiers: ['site:card'] }); + await ui.frame().getByRole('heading', { name: 'External documentation' }).waitFor(); + await ui.frame().locator('.tiptap').click(); + await ui.frame().getByRole('button', { name: 'Edit Markdown source' }).click(); + await ui.frame().locator('textarea').fill('# My unsaved documentation\n'); + const loads = await page.evaluate(() => contentLoads); + const docRequests = documents.length; + const before = [...ui.previews]; + server.markdown = '# A later disk change\n'; server.revision = 'r3'; + server.fixture = 'Changed fixture'; + const previewReloads = variants.map(name => page.waitForResponse(response => { + const url = new URL(response.url()); + return url.pathname === '/preview' && url.searchParams.get('componentVariant') === 'site:card:' + name; + })); + await ui.emit({ componentIdentifiers: ['site:card'], documentationComponentIdentifiers: ['site:card'] }); + await Promise.all(previewReloads); + await ui.frame().waitForFunction(() => window.view.previewChanged); + for (const name of variants) { + const identifier = 'site:card:' + name; + await ui.frame().frameLocator(`iframe[data-variant-identifier="${identifier}"]`).getByText('Changed fixture').waitFor(); + assert.ok(ui.previews.filter(value => value === identifier).length > before.filter(value => value === identifier).length, name + ' preview must refresh'); + } + assert.equal(await page.evaluate(() => contentLoads), loads); + assert.equal(await ui.frame().locator('textarea').inputValue(), '# My unsaved documentation\n'); + assert.equal(documents.length, docRequests, 'dirty documentation must not be fetched over local edits'); + await ui.frame().getByRole('button', { name: 'Reset', exact: true }).click(); + await ui.frame().waitForFunction(() => document.querySelector('textarea').value === '# A later disk change\n'); + await ui.frame().getByRole('heading', { name: 'A later disk change' }).waitFor(); + await ui.emit({ componentIdentifiers: ['site:card'] }); + await page.waitForFunction(before => contentLoads > before, loads); + await ui.ready(type === 'Overview'); + assert.deepEqual(await page.evaluate(() => streams.map(stream => ({ uri: stream.uri, closed: stream.closed }))), [{ uri: '/changes?token=stream', closed: false }]); + }); +} diff --git a/Tests/browser/componentOverviewNavigation.test.ts b/Tests/browser/componentOverviewNavigation.test.ts new file mode 100644 index 00000000..01c625e1 --- /dev/null +++ b/Tests/browser/componentOverviewNavigation.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createContext, SourceTextModule, SyntheticModule } from 'node:vm'; +import test from 'node:test'; + +async function setup() { + let current = 'https://example.test/typo3/module?componentVariant=site:card:Default&site=preview&language=de-DE'; + const navigations: URL[] = []; + const persisted: { identifier: string; treeIdentifier: string }[] = []; + const order: string[] = []; + let storedState = { identifier: 'site:card:Default', treeIdentifier: 'site_site:card_site:card:Default' }; + const document = new EventTarget(); + document.addEventListener('frontend-studio:before-navigate', () => order.push('confirm')); + const context = createContext({ + URL, CustomEvent, Event, EventTarget, customElements: { define() {} }, + top: { document, TYPO3: { ModuleMenu: { App: { getCurrentModule: () => 'admin_frontendstudio' } } } }, + window: { location: { origin: 'https://example.test' } }, + }); + const dependencies: Record> = { + lit: { LitElement: class {}, html() {} }, + '@typo3/backend/tree/tree.js': { Tree: class {} }, + '@typo3/backend/tree/tree-node.js': { TreeNodePositionEnum: {} }, + '@typo3/backend/module.js': { ModuleUtility: { getFromName: () => ({ link: '/typo3/module' }) } }, + '@typo3/backend/viewport.js': { default: { ContentContainer: { + get: () => ({ location: { href: current } }), getUrl: () => current, + setUrl: (url: URL) => { current = url.toString(); navigations.push(url); order.push('navigate'); }, + } } }, + '@typo3/backend/enum/severity.js': { SeverityEnum: {} }, + '@typo3/backend/tree/tree-toolbar.js': { TreeToolbar: class {} }, + '@typo3/backend/storage/module-state-storage.js': { ModuleStateStorage: { + current: () => storedState, + updateWithTreeIdentifier: (_type: string, identifier: string, treeIdentifier: string) => { + storedState = { identifier, treeIdentifier }; + persisted.push(storedState); + order.push('persist'); + }, + } }, + }; + const module = new SourceTextModule(await readFile(new URL('../../Resources/Public/JavaScript/Backend/component-tree-container.js', import.meta.url), 'utf8'), { context }); + await module.link(async (specifier) => { + const values = dependencies[specifier] ?? { default: class {} }; + const dependency = new SyntheticModule(Object.keys(values), function () { + for (const [name, value] of Object.entries(values)) this.setExport(name, value); + }, { context }); + await dependency.link(() => { throw new Error('Unexpected dependency'); }); + return dependency; + }); + await module.evaluate(); + const Container = (module.namespace as any).FrontendStudioComponentTreeContainer; + const container = new Container(); + const folder = { nodeType: 'folder', identifier: 'site:folder', __treeIdentifier: 'site_folder', depth: 0, checked: false }; + const nestedFolder = { ...folder, identifier: 'site:folder:nested', __treeIdentifier: 'site_folder_nested', depth: 1 }; + const component = { nodeType: 'component', identifier: 'site:card', __treeIdentifier: 'site_site:card', depth: 2, checked: false }; + const variant = { nodeType: 'variant', identifier: 'site:card:Default', __treeIdentifier: storedState.treeIdentifier, depth: 3, checked: true }; + type Node = typeof component; + const nodes = [folder, nestedFolder, component, variant]; + container.tree = { + nodes, + getSelectedNodes: () => nodes.filter((node) => node.checked), + resetSelectedNodes: () => nodes.forEach((node) => { node.checked = false; }), + expandNodeParents: async () => {}, focusNode() {}, + selectNode: (node: Node, propagate = true) => { + container.tree.resetSelectedNodes(); + node.checked = true; + return container.loadVariant({ detail: { node, propagate } }); + }, + }; + return { + container, folder, component, variant, document, navigations, persisted, order, + select: (node: Node, propagate = true) => container.tree.selectNode(node, propagate), + stored: () => storedState, + setCurrent: (url: string) => { current = url; }, + setStored: (node: Node) => { storedState = { identifier: node.identifier, treeIdentifier: node.__treeIdentifier }; }, + }; +} + +test('component selection opens overview while variant links and site/language remain distinct', async () => { + const ui = await setup(); + await ui.select(ui.component); + assert.equal(ui.navigations[0].searchParams.get('component'), 'site:card'); + assert.equal(ui.navigations[0].searchParams.has('componentVariant'), false); + assert.equal(ui.navigations[0].searchParams.get('site'), 'preview'); + assert.equal(ui.navigations[0].searchParams.get('language'), 'de-DE'); + assert.equal(ui.container.getNodeFromCurrentContentUrl(), ui.component); + assert.deepEqual(ui.order, ['confirm', 'persist', 'navigate']); + assert.equal(ui.stored().identifier, ui.component.identifier); + await ui.select(ui.variant); + assert.equal(ui.navigations[1].searchParams.get('componentVariant'), 'site:card:Default'); + assert.equal(ui.navigations[1].searchParams.has('component'), false); + assert.equal(ui.container.getNodeFromCurrentContentUrl(), ui.variant); + assert.equal(ui.stored().identifier, ui.variant.identifier); + assert.equal(ui.stored().treeIdentifier, ui.variant.__treeIdentifier); + ui.setCurrent(ui.navigations[0].toString()); + assert.equal(ui.container.getNodeFromCurrentContentUrl(), ui.component); +}); + +for (const firstComponentHasVariants of [true, false]) { + test(`folder selection opens the first nested component overview (has variants: ${firstComponentHasVariants})`, async () => { + const ui = await setup(); + const laterComponent = { ...ui.component, identifier: 'site:later', __treeIdentifier: 'site_later' }; + const laterVariant = { ...ui.variant, identifier: 'site:later:Default', __treeIdentifier: 'site_later_Default', checked: false }; + if (!firstComponentHasVariants) ui.container.tree.nodes.splice(ui.container.tree.nodes.indexOf(ui.variant), 1); + ui.container.tree.nodes.push(laterComponent, laterVariant); + await ui.select(ui.folder); + assert.equal(ui.container.getSelectedNode(), ui.component); + assert.equal(ui.navigations.length, 1); + assert.equal(ui.navigations[0].searchParams.get('component'), ui.component.identifier); + assert.equal(ui.navigations[0].searchParams.has('componentVariant'), false); + assert.equal(ui.navigations[0].searchParams.get('site'), 'preview'); + assert.equal(ui.navigations[0].searchParams.get('language'), 'de-DE'); + assert.equal(ui.stored().identifier, ui.component.identifier); + assert.deepEqual(ui.order, ['confirm', 'persist', 'navigate']); + }); +} + +test('cancelled folder navigation restores the open variant and preserves its stored state', async () => { + const ui = await setup(); + const stored = { ...ui.stored() }; + ui.document.addEventListener('frontend-studio:before-navigate', (event) => event.preventDefault()); + await ui.select(ui.folder); + assert.equal(ui.container.getSelectedNode(), ui.variant); + assert.equal(ui.folder.checked, false); + assert.equal(ui.component.checked, false); + assert.equal(ui.navigations.length, 0); + assert.equal(ui.persisted.length, 0); + assert.deepEqual(ui.stored(), stored); + assert.deepEqual(ui.order, ['confirm']); +}); + +test('restoring selection does not persist, confirm or navigate', async () => { + const ui = await setup(); + await ui.select(ui.component, false); + assert.equal(ui.navigations.length, 0); + assert.equal(ui.persisted.length, 0); + assert.deepEqual(ui.order, []); +}); + +for (const currentView of ['component', 'variant'] as const) { + test(`cancelled navigation from a ${currentView} keeps storage and restores the visible selection`, async () => { + const ui = await setup(); + const previous = ui[currentView]; + const target = currentView === 'component' ? ui.variant : ui.component; + const parameter = currentView === 'component' ? 'component' : 'componentVariant'; + ui.setCurrent(`https://example.test/typo3/module?${parameter}=${previous.identifier}`); + ui.setStored(previous); + const stored = { ...ui.stored() }; + ui.document.addEventListener('frontend-studio:before-navigate', (event) => { + assert.equal(target.checked, true, 'TYPO3 has already selected the clicked node'); + assert.deepEqual(ui.stored(), stored, 'storage must remain unchanged before confirmation'); + event.preventDefault(); + }); + await ui.select(target); + assert.equal(ui.navigations.length, 0); + assert.equal(ui.persisted.length, 0); + assert.deepEqual(ui.stored(), stored); + assert.equal(previous.checked, true); + assert.equal(target.checked, false); + assert.equal(ui.container.getSelectedNode(), previous); + assert.equal(ui.container.getNodeFromCurrentContentUrl(), previous); + assert.deepEqual(ui.order, ['confirm'], 'restoring must not ask again'); + }); +} + +test('cancelled navigation restores stored selection when the content URL has no matching node', async () => { + const ui = await setup(); + ui.setCurrent('https://example.test/typo3/module'); + ui.document.addEventListener('frontend-studio:before-navigate', (event) => event.preventDefault()); + await ui.select(ui.component); + assert.equal(ui.container.getSelectedNode(), ui.variant); + assert.equal(ui.persisted.length, 0); + assert.equal(ui.navigations.length, 0); +}); + +test('cancelled navigation clears selection if no previous node exists', async () => { + const ui = await setup(); + ui.setCurrent('https://example.test/typo3/module'); + ui.setStored({ ...ui.variant, identifier: 'site:removed', __treeIdentifier: 'removed' }); + ui.document.addEventListener('frontend-studio:before-navigate', (event) => event.preventDefault()); + await ui.select(ui.component); + assert.equal(ui.container.getSelectedNode(), null); + assert.equal(ui.persisted.length, 0); + assert.equal(ui.navigations.length, 0); +}); diff --git a/Tests/browser/componentTreeFiltering.mjs b/Tests/browser/componentTreeFiltering.mjs new file mode 100644 index 00000000..d18d1f70 --- /dev/null +++ b/Tests/browser/componentTreeFiltering.mjs @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test, { before, after } from 'node:test'; +import { chromium, expect } from '@playwright/test'; +import { typo3ReleaseModule } from './helpers/typo3Release.mjs'; + +const resources = new URL('../../Resources/Public/', import.meta.url); +const component = 'site:card'; +const target = 'site:card:Mobile:Dark'; +const nodes = [ + { identifier: 'site', nodeType: 'folder', name: 'Site', depth: 0, hasChildren: true }, + { identifier: component, nodeType: 'component', name: 'Card', depth: 1, hasChildren: true }, + ...['Default', 'Mobile:Dark'].map(name => ({ identifier: `${component}:${name}`, nodeType: 'variant', name, depth: 2, hasChildren: false })), +]; +const imports = { + '@andersundsehr/frontend-studio/backend/': '/Backend/', + '@andersundsehr/frontend-studio/vendor/inline-documentation-editor': '/Contrib/inline-documentation-editor.js', + '@andersundsehr/frontend-studio/vendor/markdown-converter': '/Contrib/markdown-converter.js', + '@andersundsehr/frontend-studio/vendor/code-highlighting': '/Contrib/code-highlighting.js', + '@typo3/backend/': '/typo3/backend/', + '@typo3/core/': '/typo3/core/', + '~labels/': '/labels/', + bootstrap: '/empty.js', +}; +for (const name of ['lit', 'lit-html', 'lit-element', '@lit/reactive-element']) { + const entry = name === 'lit-html' ? 'lit-html.js' : name === '@lit/reactive-element' ? 'reactive-element.js' : 'index.js'; + imports[name] = `/typo3/core/Contrib/${name}/${entry}`; + imports[`${name}/`] = `/typo3/core/Contrib/${name}/`; +} +const head = ``; +let browser; +before(async () => { browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); }); +after(async () => { await browser?.close(); }); + +async function setup(t, version) { + const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); + page.setDefaultTimeout(15000); + const errors = []; + const requests = []; + const loadedModules = new Set(); + page.on('pageerror', error => errors.push(error.message)); + t.after(async () => { await page.close(); assert.deepEqual(errors, []); }); + // Only unrelated backend chrome is stubbed. Tree, TreeToolbar, Lit, debounce, + // Ajax and both storage APIs come from the pinned TYPO3 release, unchanged. + const stubs = { + '/empty.js': '', + '/typo3/backend/element/icon-element.js': '', + '/typo3/backend/viewport/content-navigation-toggle.js': '', + '/typo3/backend/modal.js': 'export default {};', + '/typo3/backend/notification.js': 'export default { success() {}, error(...args) { throw new Error(JSON.stringify(args)); } };', + '/typo3/backend/page-wizard/helper/wizard-helper.js': 'export function openPageWizardModal() {}', + '/typo3/backend/module.js': 'export const ModuleUtility = { getFromName: () => ({ link: "/module?token=module" }) };', + '/typo3/backend/viewport.js': 'export default top.viewport;', + '/typo3/core/document-service.js': 'export default { ready: () => Promise.resolve() };', + '/typo3/core/lit-helper.js': 'export const lll = key => key;', + }; + await page.route('https://studio.test/**', async route => { + const url = new URL(route.request().url()); + const path = url.pathname; + const javascript = body => route.fulfill({ contentType: 'text/javascript', body }); + if (path in stubs) return javascript(stubs[path]); + if (path.startsWith('/labels/')) return javascript('export default { get: key => key };'); + if (path.startsWith('/typo3/')) { + const [, , extension, ...parts] = path.split('/'); + loadedModules.add(`${extension}/${parts.join('/')}`); + return javascript(await typo3ReleaseModule(version, extension, parts.join('/'))); + } + const file = path.startsWith('/Backend/') ? `JavaScript${path}` : path.startsWith('/Contrib/') ? path.slice(1) : null; + if (file) return javascript(await readFile(new URL(file, resources), 'utf8')); + if (path === '/tree-data' || path === '/tree-filter') { + requests.push(path); + // A delayed reload exercises TYPO3 13's void resetFilter() return value. + if (path === '/tree-data' && requests.filter(value => value === path).length > 1) { + await new Promise(resolve => setTimeout(resolve, 150)); + } + return route.fulfill({ json: path === '/tree-filter' ? nodes.filter(node => node.identifier !== target) : nodes }); + } + if (path === '/docs') return route.fulfill({ json: { markdown: '# Card\n\nOriginal documentation\n', revision: 'r1', readOnly: false } }); + if (path === '/backend') return route.fulfill({ contentType: 'text/html', body: `${head} + + + + ` }); + const overview = url.searchParams.has('component'); + return route.fulfill({ contentType: 'text/html', body: `${head}
+
+
+
+

+
+ ${['Default', 'Mobile:Dark'].map(name => `${name}`).join('')} +
` }); + }); + await page.goto('https://studio.test/backend'); + await page.waitForFunction(() => window.container?.treeInitialized && document.querySelector('#content').contentWindow.ready); + const content = page.frameLocator('#content'); + await expect(content.locator('[contenteditable="true"]')).toBeVisible(); + // Exercise expansion and its real localStorage persistence when navigating. + await page.evaluate(() => window.container.tree.nodes.filter(node => node.hasChildren).forEach(node => window.container.tree.hideChildren(node))); + for (const file of ['tree/tree.js', 'tree/tree-toolbar.js', 'storage/module-state-storage.js', 'storage/client.js']) { + assert.ok(loadedModules.has(`backend/${file}`), `${file} must use the real TYPO3 ${version} implementation`); + } + const search = page.locator('.search-input'); + const state = () => page.evaluate(() => ({ + searchTerm: window.container.tree.searchTerm, + identifiers: window.container.tree.nodes.map(node => node.identifier), + selected: window.container.tree.getSelectedNodes().map(node => node.identifier), + focused: window.container.tree.focusedNode?.identifier, + stored: JSON.parse(sessionStorage.getItem('t3-module-state-frontend_studio_component_tree')), + expanded: window.container.tree.nodes.filter(node => node.hasChildren).map(node => node.__expanded), + order: window.order, + navigations: window.navigations, + })); + const filter = async () => { + await search.fill('Default'); + await page.waitForFunction(target => window.container.tree.searchTerm === 'Default' && !window.container.tree.loading && !window.container.tree.nodes.some(node => node.identifier === target), target); + await expect(page.locator(`[role="treeitem"][data-id="${target}"]`)).toHaveCount(0); + await expect(search).toHaveValue('Default'); + }; + const assertNavigation = async identifier => { + await page.waitForFunction(identifier => window.navigations.length === 1 && document.querySelector('#content').contentWindow.ready && window.container.tree.focusedNode?.identifier === identifier, identifier); + const result = await state(); + assert.deepEqual(result.selected, [identifier]); + assert.deepEqual(result.stored, { identifier, treeIdentifier: `site_${component}_${identifier}` }); + assert.deepEqual(result.expanded, [true, true]); + assert.deepEqual(result.order, ['confirm', 'persist', 'navigate']); + const destination = new URL(result.navigations[0]); + assert.equal(destination.searchParams.get('componentVariant'), identifier); + assert.equal(destination.searchParams.has('component'), false); + assert.equal(destination.searchParams.get('site'), 'preview'); + assert.equal(destination.searchParams.get('language'), 'de-DE'); + assert.equal(destination.searchParams.get('token'), 'module'); + await expect(page.locator(`[role="treeitem"][data-id="${identifier}"]`)).toBeVisible(); + const persistedExpansion = await page.evaluate(() => JSON.parse(localStorage.getItem('t3-tree-state-frontend-studio-component-tree'))); + assert.equal(persistedExpansion.site.expanded, true); + // TYPO3 temporarily expands filtered descendants without changing saved expansion. + assert.equal(persistedExpansion[`site_${component}`].expanded, result.searchTerm === ''); + }; + return { page, content, search, filter, state, requests, assertNavigation }; +} + +for (const version of ['13.4.35', '14.3.7']) { + for (const selected of [true, false]) { + test(`TYPO3 ${version}: excluded Overview link resets the filter with ${selected ? 'cached selection' : 'an asynchronous reload'}`, async t => { + const ui = await setup(t, version); + await ui.filter(); + if (selected) await ui.page.evaluate(() => window.container.tree.selectNode(window.container.tree.nodes.find(node => node.nodeType === 'component'), false)); + else assert.deepEqual((await ui.state()).selected, []); + await ui.content.getByRole('link', { name: 'Mobile:Dark', exact: true }).click(); + await ui.assertNavigation(target); + await expect(ui.search).toHaveValue(''); + assert.equal((await ui.state()).searchTerm, ''); + assert.deepEqual((await ui.state()).identifiers, nodes.map(node => node.identifier)); + assert.equal(ui.requests.filter(path => path === '/tree-data').length, selected ? 1 : 2); + // Refresh must also respect the cleared state, rather than reapplying the old query. + await ui.page.evaluate(() => window.container.toolbar.refreshTree()); + await ui.page.waitForFunction(() => !window.container.tree.loading); + assert.equal(ui.requests.filter(path => path === '/tree-filter').length, 1); + }); + } + + test(`TYPO3 ${version}: cancelling dirty documentation preserves the filter; approving asks once`, async t => { + const ui = await setup(t, version); + await ui.filter(); + const editor = ui.content.locator('[contenteditable="true"]'); + await editor.click(); + await editor.press('End'); + await editor.pressSequentially(' Unsaved edit'); + await expect(ui.content.locator('[data-doc-save]')).toBeEnabled(); + const original = await ui.state(); + const originalRequests = [...ui.requests]; + const dialogs = []; + let approve = false; + ui.page.on('dialog', async dialog => { + dialogs.push({ type: dialog.type(), message: dialog.message() }); + if (approve) await dialog.accept(); else await dialog.dismiss(); + }); + const link = ui.content.getByRole('link', { name: 'Mobile:Dark', exact: true }); + await link.click(); + await expect.poll(() => dialogs.length).toBe(1); + assert.deepEqual(dialogs[0], { type: 'confirm', message: 'Discard unsaved documentation changes?' }); + await expect(ui.search).toHaveValue('Default'); + const cancelled = await ui.state(); + assert.deepEqual({ ...cancelled, order: [] }, original); + assert.deepEqual(ui.requests, originalRequests); + await expect(editor).toContainText('Unsaved edit'); + await expect(ui.content.locator('[data-doc-save]')).toBeEnabled(); + await ui.page.evaluate(() => { window.order = []; }); + approve = true; + await link.click(); + await ui.assertNavigation(target); + assert.equal(dialogs.length, 2, 'Discard approval must not be requested again during selection or iframe unload'); + await expect(ui.search).toHaveValue(''); + assert.equal((await ui.state()).searchTerm, ''); + assert.deepEqual(await ui.page.evaluate(() => window.approvals), [ + { searchTerm: 'Default', input: 'Default' }, { searchTerm: 'Default', input: 'Default' }, + ], 'Both confirmations must run before the filter is changed'); + }); + + test(`TYPO3 ${version}: a variant already in the filtered tree keeps its filter`, async t => { + const ui = await setup(t, version); + await ui.filter(); + await ui.content.getByRole('link', { name: 'Default', exact: true }).click(); + await ui.assertNavigation(`${component}:Default`); + await expect(ui.search).toHaveValue('Default'); + assert.equal((await ui.state()).searchTerm, 'Default'); + assert.equal(ui.requests.filter(path => path === '/tree-data').length, 1); + await expect(ui.page.locator(`[role="treeitem"][data-id="${target}"]`)).toHaveCount(0); + }); +} diff --git a/Tests/browser/documentationRequests.test.ts b/Tests/browser/documentationRequests.test.ts index decf6bd7..eb99b2eb 100644 --- a/Tests/browser/documentationRequests.test.ts +++ b/Tests/browser/documentationRequests.test.ts @@ -14,9 +14,10 @@ async function setup({ deferEditor = false, readOnly = false, initialMarkdown = const bodies: any[] = []; const requests: any[] = []; const notifications: string[] = []; - const window = Object.assign(new EventTarget(), { location: { href: 'https://example.test' }, confirm: () => true }); + const window = Object.assign(new EventTarget(), { location: { href: 'https://example.test' }, confirm: (): boolean => true }); + const document = new EventTarget(); const context = createContext({ - Event, EventTarget, AbortController, URL, URLSearchParams, window, top: { document: new EventTarget() }, + Event, EventTarget, AbortController, URL, URLSearchParams, window, top: { document }, fetch: async (_url: URL, options: any) => { requests.push({ url: _url, ...options }); bodies.push(options.body ? Object.fromEntries(new URLSearchParams(options.body)) : null); @@ -50,10 +51,52 @@ async function setup({ deferEditor = false, readOnly = false, initialMarkdown = }; await respond({ markdown: initialMarkdown, revision: 'r1', readOnly }); if (!deferEditor && !readOnly) { editors[0].resolve(); await tick(); } - return { doc, root, view, bodies, requests, respond, editors, elements, notifications, tick, status: elements.get('[data-doc-status]'), + return { doc, root, view, bodies, requests, respond, editors, elements, notifications, tick, window, document, status: elements.get('[data-doc-status]'), edit: (markdown: string) => editors.at(-1).change(markdown) }; } +test('approved tree navigation prompts once while cancelled navigation retains unload protection and unsaved Markdown', async (t) => { + const ui = await setup(); t.after(() => ui.doc.destroy()); + let confirmations = 0; + let approve = false; + ui.window.confirm = () => { confirmations++; return approve; }; + ui.edit('Unsaved documentation'); + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }); + ui.window.dispatchEvent(event); + return event.defaultPrevented; + }; + assert.equal(ui.document.dispatchEvent(new Event('frontend-studio:before-navigate', { cancelable: true })), false); + assert.equal(unload(), true); + assert.equal(ui.doc.markdown, 'Unsaved documentation'); + approve = true; + assert.equal(ui.document.dispatchEvent(new Event('frontend-studio:before-navigate', { cancelable: true })), true); + assert.equal(unload(), false, 'approved navigation must not display a second prompt'); + assert.equal(unload(), true, 'approval only applies to the next unload'); + assert.equal(confirmations, 2); + assert.equal(ui.doc.dirty, true); + assert.equal(ui.view.documentationDirty, true); + assert.equal(ui.doc.baseline, 'Original\n'); +}); + +test('navigation cancelled by another guard and edits after approval retain unload protection', async (t) => { + const ui = await setup(); t.after(() => ui.doc.destroy()); + ui.edit('Unsaved documentation'); + const cancel = (event: Event) => event.preventDefault(); + ui.document.addEventListener('frontend-studio:before-navigate', cancel); + assert.equal(ui.document.dispatchEvent(new Event('frontend-studio:before-navigate', { cancelable: true })), false); + let unload = new Event('beforeunload', { cancelable: true }); + ui.window.dispatchEvent(unload); + assert.equal(unload.defaultPrevented, true); + ui.document.removeEventListener('frontend-studio:before-navigate', cancel); + ui.document.dispatchEvent(new Event('frontend-studio:before-navigate', { cancelable: true })); + ui.edit('New edits after approval'); + unload = new Event('beforeunload', { cancelable: true }); + ui.window.dispatchEvent(unload); + assert.equal(unload.defaultPrevented, true); + assert.equal(ui.doc.markdown, 'New edits after approval'); +}); + test('the editor always opens without modifying original Markdown and exposes Controls-style dirty actions', async (t) => { const ui = await setup(); t.after(() => ui.doc.destroy()); assert.ok(ui.doc.editor); diff --git a/Tests/browser/helpers/typo3Release.mjs b/Tests/browser/helpers/typo3Release.mjs new file mode 100644 index 00000000..8bcee553 --- /dev/null +++ b/Tests/browser/helpers/typo3Release.mjs @@ -0,0 +1,27 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +const pending = new Map(); + +// Read released JavaScript verbatim, including TYPO3's own Lit, Ajax and storage +// implementations. Cache it in the ignored var directory for subsequent offline runs. +export function typo3ReleaseModule(version, extension, path) { + const key = `${version}/${extension}/${path}`; + if (!pending.has(key)) pending.set(key, load()); + return pending.get(key); + + async function load() { + const file = new URL(`../../../var/.cache/typo3-tree/${key}`, import.meta.url); + try { + return await readFile(file, 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const url = `https://raw.githubusercontent.com/TYPO3-CMS/${extension}/v${version}/Resources/Public/JavaScript/${path}`; + const response = await fetch(url, { signal: AbortSignal.timeout(30000) }); + if (!response.ok) throw new Error(`TYPO3 fixture download failed: ${response.status} ${url}`); + const source = await response.text(); + await mkdir(new URL('.', file), { recursive: true }); + await writeFile(file, source); + return source; + } +} diff --git a/Tests/browser/previewBackground.mjs b/Tests/browser/previewBackground.mjs new file mode 100644 index 00000000..8d06ab12 --- /dev/null +++ b/Tests/browser/previewBackground.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test, { before, after } from 'node:test'; + +import { chromium } from '@playwright/test'; +const resources = new URL('../../Resources/Public/', import.meta.url); +const css = (await Promise.all(['variant-view.css', 'component-overview.css'].map(name => readFile(new URL(`Css/Backend/${name}`, resources), 'utf8')))).join('\n'); +const middleware = await readFile(new URL('../../Classes/Middleware/ComponentPreviewMiddleware.php', import.meta.url), 'utf8'); +const previewCss = middleware.match(/\$css = <<<'EOF'\n([\s\S]*?)\nEOF;/)[1]; +let browser; +before(async () => { browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); }); +after(async () => { await browser?.close(); }); + +for (const scheme of ['light', 'dark']) { + test(`Docs previews share one continuous ${scheme} background through resize, reload and scrolling`, async t => { + const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, colorScheme: scheme }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + t.after(async () => { assert.deepEqual(errors, []); await page.close(); }); + await page.route('https://studio.test/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname === '/__frontendStudio/preview') { + // The endpoint's own background and a site-level HTML background must + // both yield to the enclosing Docs frame, while retaining body margins. + return route.fulfill({ contentType: 'text/html', body: `
` }); + } + if (url.pathname === '/document-service.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default { ready: () => Promise.resolve() };' }); + const file = url.pathname.startsWith('/Backend/') ? `JavaScript${url.pathname}` : url.pathname.startsWith('/Contrib/') ? url.pathname.slice(1) : null; + if (file) return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL(file, resources), 'utf8') }); + const examples = ['Default', 'Other'].map((name, index) => `

`).join(''); + return route.fulfill({ contentType: 'text/html', body: `
${examples}
` }); + }); + await page.goto('https://studio.test/'); + const frames = page.locator('[data-overview-frame]'); + const containers = page.locator('[data-overview-frame-container]'); + const waitForCanvas = () => page.waitForFunction(() => [...document.querySelectorAll('[data-overview-frame]')].every(frame => frame.contentDocument?.body && frame.contentDocument.documentElement.style.background === 'transparent' && frame.contentDocument.body.style.background === 'transparent' && parseFloat(frame.style.height) > 80)); + const checkPattern = async container => { + const visible = await container.screenshot(); + await container.locator('iframe').evaluate(frame => { frame.style.visibility = 'hidden'; }); + const backgroundOnly = await container.screenshot(); + await container.locator('iframe').evaluate(frame => { frame.style.removeProperty('visibility'); }); + assert.ok(visible.equals(backgroundOnly), 'An empty preview must not introduce a separate checkerboard or a seam at the iframe boundary'); + }; + for (const width of [1100, 390, 777]) { + await page.setViewportSize({ width, height: 900 }); + await frames.last().scrollIntoViewIfNeeded(); + await waitForCanvas(); + for (let index = 0; index < 2; index++) { + await checkPattern(containers.nth(index)); + const { padding, pattern } = await containers.nth(index).evaluate(node => ({ padding: parseFloat(getComputedStyle(node).paddingTop), pattern: getComputedStyle(node).backgroundImage })); + assert.ok(Math.abs(padding - Math.min(Math.max(0.7 * 16, width * 0.015), 16)) < 0.02); + assert.ok(pattern.includes(scheme === 'dark' ? 'rgb(221, 221, 221)' : 'rgb(255, 255, 255)')); + } + } + // Exercise the shared live-preview handler, including a newly loaded document. + await page.evaluate(async () => { + const { getVariantState } = await import('/Backend/variant-state.js'); + const view = getVariantState(document.querySelector('main')); + view.previewUri += '&long=1'; + view.changed('context'); + }); + await page.waitForFunction(() => [...document.querySelectorAll('[data-overview-frame]')].every(frame => parseFloat(frame.style.height) >= 650)); + await waitForCanvas(); + await containers.first().evaluate(node => { node.scrollTop = 53; }); + assert.equal(await containers.first().evaluate(node => node.scrollTop), 53); + await checkPattern(containers.first()); + await page.evaluate(() => document.querySelector('[data-overview-frame-container]').classList.add('is-expanded')); + await checkPattern(containers.first()); + + // Standalone previews keep the endpoint background; only Docs uses a + // transparent canvas. Compare it with the inspector's surrounding surface. + await page.goto('https://studio.test/__frontendStudio/preview?componentVariant=site:card:Default'); + const standalone = await page.locator('body').evaluate(node => getComputedStyle(node).backgroundImage); + assert.ok(standalone.includes(scheme === 'dark' ? 'rgb(221, 221, 221)' : 'rgb(255, 255, 255)')); + await page.addStyleTag({ content: css }); + assert.equal(await page.evaluate(() => { + const surface = document.createElement('div'); + surface.className = 'frontend-studio-variant-preview'; + document.body.append(surface); + return getComputedStyle(surface).backgroundImage; + }), standalone); + }); +}