diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 6859186ff..a641c513e 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -3,23 +3,21 @@ import { defineConfig } from 'astro/config'; import { unified } from '@astrojs/markdown-remark'; import { sidebarTopics } from './config/sidebar/sidebar.topics.ts'; import { redirects } from './config/redirects.mjs'; -import { iconPacks } from './config/icon-packs.mjs'; import { locales } from './config/locales.ts'; import { headAttrs } from './config/head.attrs.ts'; import { socialConfig } from './config/socials.config.ts'; import { aspireVersionPlaceholdersIntegration } from './config/aspire-version-placeholders-integration.mjs'; import { remarkAspireVersionPlaceholders } from './config/remark-aspire-version-placeholders.mjs'; import { remarkTypeScriptFirstAppHostTabs } from './config/remark-typescript-first-apphost-tabs.mjs'; +import { remarkMermaid } from './config/remark-mermaid.mjs'; import catppuccin from '@catppuccin/starlight'; import lunaria from './config/lunaria-starlight.mjs'; -import mermaid from 'astro-mermaid'; import mdx from '@astrojs/mdx'; import starlightGitHubAlerts from 'starlight-github-alerts'; import starlightImageZoom from 'starlight-image-zoom'; import starlightKbd from 'starlight-kbd'; import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsTxt from 'starlight-llms-txt'; -import starlightScrollToTop from 'starlight-scroll-to-top'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import buildTiming from './config/build-timing.mjs'; @@ -62,7 +60,7 @@ export default defineConfig({ trailingSlash: 'always', markdown: { processor: unified({ - remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders], + remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders, remarkMermaid], }), }, redirects: redirects, @@ -134,31 +132,6 @@ export default defineConfig({ errorOnFallbackPages: false, exclude: ['/i18n/', '/reference/api', '/reference/api/**'], }), - starlightScrollToTop({ - // https://frostybee.github.io/starlight-scroll-to-top/svg-paths/ - svgPath: 'M4 16L12 8L20 16', - showTooltip: true, - threshold: 10, - showOnHomepage: true, - svgStrokeWidth: 4, - tooltipText: { - da: 'Rul op', - de: 'Nach oben scrollen', - en: 'Scroll to top', - es: 'Ir arriba', - fr: 'Retour en haut', - hi: 'ऊपर स्क्रॉल करें', - id: 'Gulir ke atas', - it: 'Torna su', - ja: 'トップへ戻る', - ko: '맨 위로', - 'pt-br': 'Voltar ao topo', - ru: 'Наверх', - tr: 'Başa dön', - uk: 'Прокрутити вгору', - 'zh-cn': '回到顶部', - }, - }), starlightGitHubAlerts(), starlightLlmsTxt({ projectName: 'Aspire', @@ -230,11 +203,6 @@ export default defineConfig({ ], }, }), - mermaid({ - theme: 'forest', - autoTheme: true, - iconPacks, - }), mdx({ optimize: true, gfm: true, diff --git a/src/frontend/config/icon-packs.mjs b/src/frontend/config/icon-packs.mjs index 3e852800a..10df0c11e 100644 --- a/src/frontend/config/icon-packs.mjs +++ b/src/frontend/config/icon-packs.mjs @@ -1,6 +1,5 @@ // Icon packs configuration for Mermaid diagrams. -// astro-mermaid now serializes icon packs as name/url pairs, so custom packs -// must be served from a static JSON endpoint instead of an inline loader. +// Loaded lazily by src/scripts/mermaid.ts. export const iconPacks = [ { diff --git a/src/frontend/config/remark-mermaid.mjs b/src/frontend/config/remark-mermaid.mjs new file mode 100644 index 000000000..77efff657 --- /dev/null +++ b/src/frontend/config/remark-mermaid.mjs @@ -0,0 +1,24 @@ +/** Turn opted-in fences into plain pre elements before code highlighting. */ +export function remarkMermaid() { + return (tree) => { + function visit(parent) { + for (const [index, node] of parent.children.entries()) { + if (node.type === 'code' && node.lang === 'mermaid') { + parent.children[index] = { + type: 'paragraph', + children: [], + data: { + hName: 'pre', + hProperties: { className: ['mermaid'] }, + hChildren: [{ type: 'text', value: node.value }], + }, + position: node.position, + }; + } else if (Array.isArray(node.children)) { + visit(node); + } + } + } + visit(tree); + }; +} diff --git a/src/frontend/package.json b/src/frontend/package.json index b7468f0d5..182ff55e4 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -81,7 +81,6 @@ "astro": "^7.2.8", "astro-contributors": "^0.9.0", "astro-expressive-code": "^0.44.1", - "astro-mermaid": "^2.1.0", "astro-tooltips": "^0.6.2", "hast-util-to-html": "^9.0.5", "mdast-util-to-hast": "^13.2.1", @@ -97,7 +96,6 @@ "starlight-llms-txt": "^0.11.0", "starlight-page-actions": "^0.7.0", "starlight-plugin-icons": "^1.1.6", - "starlight-scroll-to-top": "^1.0.1", "starlight-sidebar-topics": "^0.8.0" }, "devDependencies": { diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index 5e58796e7..393a24363 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -45,7 +45,6 @@ overrides: patchedDependencies: '@astrojs/starlight@0.41.3': ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5 starlight-plugin-icons@1.1.6: ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54 - importers: .: @@ -107,9 +106,6 @@ importers: astro-expressive-code: specifier: ^0.44.1 version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) - astro-mermaid: - specifier: ^2.1.0 - version: 2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1) astro-tooltips: specifier: ^0.6.2 version: 0.6.2 @@ -155,9 +151,6 @@ importers: starlight-plugin-icons: specifier: ^1.1.6 version: 1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3) - starlight-scroll-to-top: - specifier: ^1.0.1 - version: 1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) starlight-sidebar-topics: specifier: ^0.8.0 version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) @@ -1990,16 +1983,6 @@ packages: peerDependencies: astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 - astro-mermaid@2.1.0: - resolution: {integrity: sha512-fFRUN0BTZh+DZhDiLyblXoO26XqJ1Rr+qK3JGgSu7OBspKHDm59jkztg/aHsrdo1vO/tIq/+xhP/vgT8Mp92XA==} - peerDependencies: - '@mermaid-js/layout-elk': ^0.2.0 - astro: '>=4' - mermaid: ^10.0.0 || ^11.0.0 - peerDependenciesMeta: - '@mermaid-js/layout-elk': - optional: true - astro-tooltips@0.6.2: resolution: {integrity: sha512-I9uXbchctnRqbc0mnxKcBRfweMuql/U+619+MzNvq3kANc7xthOXj6cMNgAkTaXoHJLdFMKL3Fx6vB5cyiiRXg==} @@ -3804,12 +3787,6 @@ packages: unocss: '>=0.58.0' zod: '>=3.22.0 || >=4.0.0' - starlight-scroll-to-top@1.0.1: - resolution: {integrity: sha512-OkSVwEQBzHkYPgBYL/O9xSiFn75j6HvrmjvdL8+3Kk0oxgt4sNmkOwhB0bGjB/T7M7SAJ4AFk1Ojza1HVxOeHQ==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@astrojs/starlight': '>=0.38.0' - starlight-sidebar-topics@0.8.0: resolution: {integrity: sha512-hE8+w2vNL13h6cq3UJGl05milJQ3+1BSlhaV5V4a993YzYczU6uLAj6jfUDKQ2mmgkdVWp8/UNDF4Je/rQXNQw==} engines: {node: '>=22.12.0'} @@ -6096,14 +6073,6 @@ snapshots: rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro-mermaid@2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1): - dependencies: - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - import-meta-resolve: 4.2.0 - mdast-util-to-string: 4.0.0 - mermaid: 11.16.1 - unist-util-visit: 5.1.0 - astro-tooltips@0.6.2: dependencies: tippy.js: 6.3.7 @@ -8635,10 +8604,6 @@ snapshots: unocss: 66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) zod: 4.4.3 - starlight-scroll-to-top@1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): - dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - starlight-sidebar-topics@0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) diff --git a/src/frontend/public/scripts/analytics/1ds.js b/src/frontend/public/scripts/analytics/1ds.js index 6c1ebc9fc..7829b16f7 100644 --- a/src/frontend/public/scripts/analytics/1ds.js +++ b/src/frontend/public/scripts/analytics/1ds.js @@ -2,7 +2,6 @@ (function () { if (typeof location !== 'undefined' && location.origin !== 'https://aspire.dev') { - console.debug('[1ds] Skipping load for origin:', location.origin); return; } @@ -11,7 +10,6 @@ } if (window.analytics && window.analytics.__initialized) { - console.debug('[1ds] Already initialized, skipping.'); return; } @@ -21,6 +19,7 @@ { instrumentationKey: '1c6ad99c3e274af7881b9c3c78eed459-573e6b44-ab25-4e60-97ad-7b7f38f0243a-6923', + disablePageUnloadEvents: ['unload'], channelConfiguration: { eventsLimitInMem: 50 }, propertyConfiguration: { env: 'PROD' }, webAnalyticsConfiguration: { @@ -42,6 +41,6 @@ analytics.__initialized = true; window.analytics = analytics; } catch (err) { - console.debug('[1ds] Failed to initialize Application Insights:', err); + console.warn('[1ds] Failed to initialize Application Insights:', err); } })(); diff --git a/src/frontend/public/scripts/analytics/track.js b/src/frontend/public/scripts/analytics/track.js index 2f570a065..ade61441c 100644 --- a/src/frontend/public/scripts/analytics/track.js +++ b/src/frontend/public/scripts/analytics/track.js @@ -2,12 +2,10 @@ (function () { if (!window.analytics || !window.analytics.__initialized) { - console.debug('[track] Analytics not initialized, skipping event tracking setup.'); return; } if (window.analytics.__trackingBound) { - console.debug('[track] Event tracking already bound, skipping.'); return; } @@ -42,11 +40,9 @@ try { window.analytics.capturePageAction(target, overrides); - console.debug('[track] Event tracked:', eventName, overrides); } catch (err) { - console.debug('[track] Failed to track event:', err); + console.warn('[track] Failed to track event:', err); } }); - console.debug('[track] Event tracking bound.'); })(); diff --git a/src/frontend/src/components/InstallCliModal.astro b/src/frontend/src/components/InstallCliModal.astro index b69be4701..9e7622674 100644 --- a/src/frontend/src/components/InstallCliModal.astro +++ b/src/frontend/src/components/InstallCliModal.astro @@ -175,9 +175,15 @@ const channelOptions: CustomSelectOption[] = [ return 'release'; } + let modalListeners: AbortController | undefined; + function initializeModal() { + modalListeners?.abort(); + modalListeners = undefined; const modal = document.getElementById('install-cli-modal') as HTMLDialogElement; if (!modal) return; + modalListeners = new AbortController(); + const { signal } = modalListeners; const versionSelect = modal.querySelector('#version-select') as HTMLSelectElement; const openBtns = document.querySelectorAll('[data-open-install-modal]'); @@ -227,53 +233,44 @@ const channelOptions: CustomSelectOption[] = [ versionSelect.addEventListener('change', (e) => { const quality = (e.target as HTMLSelectElement).value as 'release' | 'staging' | 'dev'; updateVersionDisplay(quality); - }); + }, { signal }); } // Open modal and position it openBtns.forEach((openBtn) => { - if (openBtn.dataset.installModalBound === 'true') { - return; - } - - openBtn.dataset.installModalBound = 'true'; openBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(openBtn); - }); + }, { signal }); }); // Close modal handlers - closeBtn?.addEventListener('click', () => modal.close()); + closeBtn?.addEventListener('click', () => modal.close(), { signal }); // Close on backdrop click modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); - }); + }, { signal }); // Close on Escape key modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.close(); - }); + }, { signal }); // Close modal if window is resized below mobile breakpoint window.addEventListener('resize', () => { if (window.innerWidth < 800 && modal.open) modal.close(); - }); + }, { signal }); } - // Handle install button clicks via event delegation (works across page navigations) - document.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - const openBtn = target.closest('[data-open-install-modal]'); - if (!openBtn) return; - - e.preventDefault(); + document.addEventListener('astro:before-swap', () => { + modalListeners?.abort(); + modalListeners = undefined; }); // Initialize on page load if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeModal); + document.addEventListener('DOMContentLoaded', initializeModal, { once: true }); } else { initializeModal(); } diff --git a/src/frontend/src/components/PivotSelector.astro b/src/frontend/src/components/PivotSelector.astro index 4c88adf41..68c50de48 100644 --- a/src/frontend/src/components/PivotSelector.astro +++ b/src/frontend/src/components/PivotSelector.astro @@ -23,8 +23,9 @@ const renderedOptions = : options; --- -
@@ -73,7 +74,7 @@ const renderedOptions = stroke-linejoin="round"> -
+ - diff --git a/src/frontend/src/components/ScrollToTop.astro b/src/frontend/src/components/ScrollToTop.astro new file mode 100644 index 000000000..d74f1ae0c --- /dev/null +++ b/src/frontend/src/components/ScrollToTop.astro @@ -0,0 +1,184 @@ +--- +const labels: Record = { + da: 'Rul op', + de: 'Nach oben scrollen', + en: 'Scroll to top', + es: 'Ir arriba', + fr: 'Retour en haut', + hi: 'ऊपर स्क्रॉल करें', + id: 'Gulir ke atas', + it: 'Torna su', + ja: 'トップへ戻る', + ko: '맨 위로', + 'pt-br': 'Voltar ao topo', + ru: 'Наверх', + tr: 'Başa dön', + uk: 'Прокрутити вгору', + 'zh-cn': '回到顶部', +}; +const language = (Astro.locals.starlightRoute?.lang ?? 'en').toLowerCase(); +const label = labels[language] ?? labels[language.split('-')[0]] ?? labels.en; +--- + + + + + + + + diff --git a/src/frontend/src/components/pivot-selector.ts b/src/frontend/src/components/pivot-selector.ts new file mode 100644 index 000000000..4899d3b4e --- /dev/null +++ b/src/frontend/src/components/pivot-selector.ts @@ -0,0 +1,294 @@ +import { getStoredPreference, setStoredPreference } from '@utils/browser-storage'; + +const headingSelector = 'main h1, main h2, main h3, main h4, main h5, main h6'; + +function findScrollAnchor() { + let nearest: HTMLElement | undefined; + let minDistance = Infinity; + for (const heading of document.querySelectorAll(headingSelector)) { + if (heading.offsetParent === null) continue; + const distance = -heading.getBoundingClientRect().top; + if (distance >= -100 && distance < minDistance) { + minDistance = distance; + nearest = heading; + } + } + return nearest + ? { id: nearest.id, text: nearest.textContent, offset: nearest.getBoundingClientRect().top } + : undefined; +} + +function restoreScrollAnchor(anchor: ReturnType) { + if (!anchor) return; + let heading = document.getElementById(anchor.id); + if (!heading || heading.offsetParent === null) { + heading = + [...document.querySelectorAll(headingSelector)].find( + (candidate) => candidate.offsetParent !== null && candidate.textContent === anchor.text + ) ?? null; + } + if (heading) { + window.scrollTo({ + top: window.scrollY + heading.getBoundingClientRect().top - anchor.offset, + behavior: 'instant', + }); + } +} + +export class PivotSelector extends HTMLElement { + private controller?: AbortController; + + connectedCallback() { + if (this.controller) return; + this.controller = new AbortController(); + const { signal } = this.controller; + let initialized = false; + const initialize = () => { + if (initialized) return; + initialized = true; + this.initialize(signal); + }; + // A swapped element connects before Astro updates the URL and restores scroll. + document.addEventListener('astro:page-load', initialize, { + once: true, + signal, + }); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize, { + once: true, + signal, + }); + } + document.addEventListener('astro:before-swap', () => this.dispose(), { + once: true, + signal, + }); + } + + disconnectedCallback() { + this.dispose(); + } + + private dispose() { + this.controller?.abort(); + this.controller = undefined; + } + + private initialize(signal: AbortSignal) { + const key = this.dataset.pivotKey; + const selector = this.querySelector('.pivot-selector'); + if (!key || !selector) throw new Error('Pivot selector requires a key and option container.'); + const buttons = [...selector.querySelectorAll('button[data-pivot-option]')]; + const validOptions = buttons + .filter((button) => !button.disabled) + .map((button) => button.dataset.pivotOption); + const allOptions = buttons.map((button) => button.dataset.pivotOption); + const collapseButton = this.querySelector('.pivot-collapse-btn'); + const expandButton = this.querySelector('.pivot-expand-btn'); + let placeholder: HTMLDivElement | undefined; + let floatingFrame: number | undefined; + let restoreFrame: number | undefined; + let resizeTimer: number | undefined; + let autoCollapseTimer: number | undefined; + let scrollTimer: number | undefined; + let originalTop = 0; + let isFloating = false; + let isCollapsed = false; + let userExpanded = false; + let enableScrollCollapse = false; + let lastScrollTop = window.scrollY; + + signal.addEventListener( + 'abort', + () => { + window.clearTimeout(resizeTimer); + window.clearTimeout(autoCollapseTimer); + window.clearTimeout(scrollTimer); + if (floatingFrame !== undefined) window.cancelAnimationFrame(floatingFrame); + if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame); + placeholder?.remove(); + this.classList.remove('floating', 'collapsed'); + delete selector.dataset.pivotInitialized; + }, + { once: true } + ); + + const apply = (id: string, preserveScroll: boolean) => { + const anchor = preserveScroll ? findScrollAnchor() : undefined; + setStoredPreference(key, id); + // Keep destination Starlight tabs on the same AppHost language. + if (key === 'aspire-lang' && (id === 'csharp' || id === 'typescript')) { + setStoredPreference( + 'starlight-synced-tabs__aspire-lang', + id === 'csharp' ? 'C#' : 'TypeScript' + ); + document.documentElement.dataset.apphostLang = id; + } + const url = new URL(window.location.href); + url.searchParams.set(key, id); + window.history.replaceState(window.history.state, '', url); + + for (const root of document.querySelectorAll('aspire-pivot-selector')) { + if (root.dataset.pivotKey !== key) continue; + for (const button of root.querySelectorAll( + 'button[data-pivot-option]' + )) { + button.classList.toggle('active', button.dataset.pivotOption === id); + } + } + for (const block of document.querySelectorAll('[data-pivot-block]')) { + const ids = (block.dataset.pivotBlock ?? '').split(/[,;]/).map((value) => value.trim()); + // Selectors with different option sets must not hide each other's content. + if (ids.some((value) => allOptions.includes(value))) { + block.style.display = ids.includes(id) ? '' : 'none'; + } + } + if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame); + if (anchor) { + restoreFrame = window.requestAnimationFrame(() => { + restoreFrame = undefined; + restoreScrollAnchor(anchor); + }); + } + }; + + for (const button of buttons) { + button.addEventListener( + 'click', + () => { + const id = button.dataset.pivotOption; + if (!button.disabled && id && validOptions.includes(id)) apply(id, true); + }, + { signal } + ); + } + const current = [ + new URLSearchParams(window.location.search).get(key), + getStoredPreference(key), + validOptions[0], + ].find((id) => id && validOptions.includes(id)); + if (current) apply(current, false); + + const clearCollapseTimers = () => { + window.clearTimeout(autoCollapseTimer); + window.clearTimeout(scrollTimer); + autoCollapseTimer = undefined; + scrollTimer = undefined; + enableScrollCollapse = false; + }; + + const setCollapsed = (collapsed: boolean, expandedByUser = false) => { + clearCollapseTimers(); + isCollapsed = collapsed; + userExpanded = expandedByUser; + this.classList.toggle('collapsed', collapsed); + if (expandedByUser) { + // Expansion remains open for five seconds, then the next scroll can collapse it. + autoCollapseTimer = window.setTimeout(() => { + autoCollapseTimer = undefined; + enableScrollCollapse = isFloating && !isCollapsed; + }, 5000); + } + }; + + const updateFloatingState = () => { + floatingFrame = undefined; + const rect = this.getBoundingClientRect(); + const scrollTop = window.scrollY; + if (!isFloating) originalTop = scrollTop + rect.top; + const shouldFloat = scrollTop > originalTop; + if (shouldFloat === isFloating) return; + isFloating = shouldFloat; + if (isFloating) { + if (!placeholder) { + placeholder = document.createElement('div'); + placeholder.className = 'pivot-placeholder'; + placeholder.setAttribute('aria-hidden', 'true'); + this.after(placeholder); + } + placeholder.style.height = `${rect.height}px`; + placeholder.style.marginBottom = getComputedStyle(this).marginBottom; + placeholder.style.display = 'block'; + } else if (placeholder) { + placeholder.style.display = 'none'; + } + this.classList.toggle('floating', isFloating); + setCollapsed(isFloating); + }; + + const scheduleFloatingUpdate = () => { + if (floatingFrame === undefined) { + floatingFrame = window.requestAnimationFrame(updateFloatingState); + } + }; + + const updateTitles = () => { + const isDesktop = window.matchMedia('(min-width: 72rem)').matches; + for (const [button, title] of [ + [collapseButton, 'Collapse selector'], + [expandButton, 'Show selector'], + ] as const) { + if (isDesktop) button?.setAttribute('title', title); + else button?.removeAttribute('title'); + } + }; + + collapseButton?.addEventListener( + 'click', + (event) => { + event.stopPropagation(); + setCollapsed(true); + }, + { signal } + ); + expandButton?.addEventListener( + 'click', + (event) => { + event.stopPropagation(); + setCollapsed(false, true); + }, + { signal } + ); + + window.addEventListener( + 'scroll', + () => { + scheduleFloatingUpdate(); + const scrollTop = window.scrollY; + if ( + isFloating && + !isCollapsed && + userExpanded && + enableScrollCollapse && + Math.abs(scrollTop - lastScrollTop) > 10 + ) { + window.clearTimeout(scrollTimer); + scrollTimer = window.setTimeout(() => { + scrollTimer = undefined; + setCollapsed(true); + }, 150); + } + lastScrollTop = scrollTop; + }, + { passive: true, signal } + ); + window.addEventListener( + 'resize', + () => { + updateTitles(); + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + resizeTimer = undefined; + scheduleFloatingUpdate(); + }, 150); + }, + { signal } + ); + + updateTitles(); + scheduleFloatingUpdate(); + selector.dataset.pivotInitialized = 'true'; + } +} + +customElements.define('aspire-pivot-selector', PivotSelector); diff --git a/src/frontend/src/components/starlight/Footer.astro b/src/frontend/src/components/starlight/Footer.astro index eee9bdf7a..2cb26730b 100644 --- a/src/frontend/src/components/starlight/Footer.astro +++ b/src/frontend/src/components/starlight/Footer.astro @@ -7,6 +7,7 @@ import FooterLegal from '@components/FooterLegal.astro'; import FooterPreferences from '@components/FooterPreferences.astro'; import FooterResources from '@components/FooterResources.astro'; import FooterSocials from '@components/FooterSocials.astro'; +import ScrollToTop from '@components/ScrollToTop.astro'; import aspireLogo from '@assets/aspire-logo-256.svg'; import { isHomepage } from '@utils/helpers'; @@ -89,6 +90,8 @@ const commit = (import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMM + + -'); + await route.fulfill({ response, body }); + }); + await page.locator('header a[href="/docs/"]:visible').evaluate((element) => { + element.setAttribute('href', '/docs/?deployment-test=1#_top'); + }); + await page.locator('header a[href^="/docs/?deployment-test=1"]:visible').click(); + await expect(page).toHaveURL(/\/docs\/\?deployment-test=1#_top$/); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__deploymentSession'))) + .toBeUndefined(); + expect(nativeDocuments).toBe(1); + expect(executions).toEqual([]); + await expect(page.locator('site-search button[data-open-modal]')).toBeEnabled(); + await page.keyboard.press('Control+k'); + await expect(page.locator('site-search dialog[open]')).toBeVisible(); + await expect(page.locator('site-search input.pagefind-ui__search-input')).toBeVisible(); + expect(errors).toEqual([]); + }); +} + +test('native Document PiP survives same-build navigation and closes on a deployment reload', async ({ + page, + isMobile, +}) => { + test.skip(isMobile, 'Native Document Picture-in-Picture requires a desktop browser.'); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + const supported = await page.evaluate(() => 'documentPictureInPicture' in window); + test.skip(!supported, 'This browser does not support native Document Picture-in-Picture.'); + + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'native-pip-test'; + button.textContent = 'Open native PiP'; + button.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + button.addEventListener('click', () => { + const api = ( + window as Window & { + documentPictureInPicture?: { requestWindow: () => Promise }; + } + ).documentPictureInPicture; + if (!api) throw new Error('Native Document Picture-in-Picture is unavailable.'); + void api.requestWindow().then((pip) => { + pip.document.body.textContent = 'Native PiP navigation test'; + Reflect.set(window, '__nativePipTest', pip); + }); + }); + document.body.append(button); + }); + const opened = page.context().waitForEvent('page'); + await page.locator('#native-pip-test').click(); + const pipPage = await opened; + await expect + .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest')))) + .toBe(true); + await navigateClient(page, () => page.locator('header a[href="/docs/"]:visible').click()); + await expect(page).toHaveURL(/\/docs\/$/); + expect(pipPage.isClosed()).toBe(false); + await expect(pipPage.locator('body')).toHaveText('Native PiP navigation test'); + + await page.route(new URL('/', page.url()).href, async (route) => { + if (route.request().isNavigationRequest()) { + await route.continue(); + return; + } + const response = await route.fetch(); + const body = (await response.text()).replace( + /]*>/, + '' + ); + await route.fulfill({ response, body }); + }); + const closed = pipPage.waitForEvent('close'); + await page.locator('header a[href="/"]:visible').click(); + await expect(page).toHaveURL((url) => url.pathname === '/'); + await closed; + await expect + .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest')))) + .toBe(false); + expect(pipPage.isClosed()).toBe(true); +}); diff --git a/src/frontend/tests/e2e/homepage.spec.ts b/src/frontend/tests/e2e/homepage.spec.ts index c67e77bd4..59630e2b5 100644 --- a/src/frontend/tests/e2e/homepage.spec.ts +++ b/src/frontend/tests/e2e/homepage.spec.ts @@ -417,6 +417,9 @@ test('presents the application model as a live polyglot topology', async ({ page await terminalWindow.scrollIntoViewIfNeeded(); await expect(story).toHaveAttribute('data-story-playing', 'true', { timeout: 10_000 }); await expect(story).toHaveAttribute('data-story-focus', 'stage', { timeout: 10_000 }); + // Center the whole stage so clicking its tab does not scroll the terminal out of view. + await story.locator('[data-model-story-surface]').scrollIntoViewIfNeeded(); + await expect(story).toHaveAttribute('data-story-viewport-active', ''); await topologyStage.click(); await expect(story).toHaveAttribute('data-story-playing', 'false'); await expect(story).toHaveAttribute('data-story-stage', 'topology'); @@ -1433,6 +1436,16 @@ test('keeps the environment frame stable while each topology changes', async ({ ) ) .toBeGreaterThan(0.5); + await expect + .poll(() => + productionPanel.locator('.topology-node').evaluateAll((nodes) => + nodes.every((node) => { + const transform = new DOMMatrixReadOnly(getComputedStyle(node).transform); + return Math.abs(transform.m41) > 1 || Math.abs(transform.m42) > 1; + }) + ) + ) + .toBe(true); const enteringTransforms = await productionPanel .locator('.topology-node') .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform)); @@ -1449,12 +1462,14 @@ test('keeps the environment frame stable while each topology changes', async ({ ) ) .toBeLessThan(0.1); - const centeredTransforms = await productionPanel - .locator('.topology-node') - .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform)); - expect( - enteringTransforms.some((transform, index) => transform !== centeredTransforms[index]) - ).toBe(true); + await expect + .poll(async () => { + const centeredTransforms = await productionPanel + .locator('.topology-node') + .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform)); + return enteringTransforms.some((transform, index) => transform !== centeredTransforms[index]); + }) + .toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe( false diff --git a/src/frontend/tests/e2e/install-modal-navigation.spec.ts b/src/frontend/tests/e2e/install-modal-navigation.spec.ts new file mode 100644 index 000000000..d8a92587d --- /dev/null +++ b/src/frontend/tests/e2e/install-modal-navigation.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers'; + +test('install modal owns one set of listeners and disposes outgoing controls', async ({ + page, isMobile, +}) => { + test.skip(isMobile, 'Touch layouts use the full installation page.'); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => { + const close = HTMLDialogElement.prototype.close; + Reflect.set(window, '__installCloseCalls', 0); + Reflect.set(window, '__installSession', true); + HTMLDialogElement.prototype.close = function(value) { + if (this.id === 'install-cli-modal') { + Reflect.set(window, '__installCloseCalls', Number(Reflect.get(window, '__installCloseCalls')) + 1); + } + close.call(this, value); + }; + }); + + for (let visit = 0; visit < 3; visit++) { + await page.evaluate(() => { + document.dispatchEvent(new Event('astro:page-load')); + document.dispatchEvent(new Event('astro:page-load')); + }); + await page.locator('header [data-open-install-modal]:visible').click(); + const modal = page.locator('#install-cli-modal'); + await expect(modal).toBeVisible(); + const before = await page.evaluate(() => Reflect.get(window, '__installCloseCalls')); + await modal.locator('[data-close-modal]').click(); + await expect(modal).toBeHidden(); + expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(before + 1); + await modal.evaluate(element => Reflect.set(window, '__outgoingInstallModal', element)); + + const destination = visit % 2 === 0 ? '/docs/' : '/'; + await page.locator(`header a[href="${destination}"]:visible`).click(); + await expect(page).toHaveURL(url => url.pathname === destination); + expect(await page.evaluate(() => Reflect.get(window, '__installSession'))).toBe(true); + const calls = await page.evaluate(() => Reflect.get(window, '__installCloseCalls')); + await page.evaluate(() => { + const outgoing = Reflect.get(window, '__outgoingInstallModal') as HTMLDialogElement; + outgoing.dispatchEvent(new Event('click')); + outgoing.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(calls); + } + expect(errors).toEqual([]); +}); diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts index 2d83f9cc4..9d4d9ee19 100644 --- a/src/frontend/tests/e2e/pivot-selector.spec.ts +++ b/src/frontend/tests/e2e/pivot-selector.spec.ts @@ -139,3 +139,185 @@ test('app host page restores pivot state from the lang query string', async ({ p await expect(javaContent).toBeVisible(); await expect(nodeJsContent).toBeHidden(); }); + +test('first-app pivots default to TypeScript and preserve history and shared preferences', async ({ + page, +}) => { + await page.goto('/get-started/first-app/?keep=1'); + await dismissCookieConsentIfVisible(page); + const selector = page.locator('#pivot-selector-aspire-lang'); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await expect(selector.getByRole('button').first()).toHaveText('TypeScript'); + await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/); + await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeVisible(); + await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeHidden(); + + const index = await page.evaluate(() => { + const state = history.state as { index: number }; + history.replaceState({ ...state, pivotTest: 'preserved' }, ''); + return state.index; + }); + expect(index).toEqual(expect.any(Number)); + await selector.getByRole('button', { name: 'C#', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\?keep=1&aspire-lang=csharp$/); + await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeVisible(); + await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeHidden(); + await expect + .poll(() => + page.evaluate(() => ({ + state: history.state, + preference: localStorage.getItem('aspire-lang'), + tabs: localStorage.getItem('starlight-synced-tabs__aspire-lang'), + language: document.documentElement.dataset.apphostLang, + })) + ) + .toMatchObject({ + state: { index, pivotTest: 'preserved' }, + preference: 'csharp', + tabs: 'C#', + language: 'csharp', + }); + + await page.goto('/get-started/first-app/?aspire-lang=typescript&keep=2'); + await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('aspire-lang'))) + .toBe('typescript'); +}); + +test('floating pivot controls clear the TOC across its responsive breakpoint', async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium'); + await page.goto('/get-started/first-app/'); + await dismissCookieConsentIfVisible(page); + const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]'); + await expect(root.locator('.pivot-selector')).toHaveAttribute('data-pivot-initialized', 'true'); + await root.getByRole('button', { name: 'C#', exact: true }).click(); + const expand = root.getByRole('button', { name: 'Expand selector', exact: true }); + const collapse = root.getByRole('button', { name: 'Collapse selector', exact: true }); + const toc = page.locator('#starlight__on-this-page--mobile'); + + for (const width of [390, 834, 1152, 1440, 1599, 1600]) { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await expect(root).not.toHaveClass(/floating/); + await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' })); + await expect(root).toHaveClass(/floating/); + await expect(root).toHaveClass(/collapsed/); + + if (width < 1600) { + await expect(toc).toBeVisible(); + await expect + .poll(async () => { + const buttonBox = await expand.boundingBox(); + const tocBox = await toc.boundingBox(); + // The shared border can overlap by one pixel, but the control must clear the TOC. + return buttonBox && tocBox ? buttonBox.y + 1 >= tocBox.y + tocBox.height : false; + }) + .toBe(true); + } else { + await expect(toc).toBeHidden(); + } + await expand.click(); + await expect(root).not.toHaveClass(/collapsed/); + await expect(root.getByRole('button', { name: 'C#', exact: true })).toHaveClass(/active/); + await collapse.click(); + await expect(root).toHaveClass(/collapsed/); + + if (width < 1600) { + await toc.click(); + await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', true); + await toc.press('Escape'); + await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', false); + } + } +}); + +for (const transition of ['native', 'fallback'] as const) { + test(`pivot lifecycle survives repeated visits and history with ${transition} swaps`, async ({ + page, isMobile, + }) => { + test.skip(isMobile && transition === 'native', + 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.'); + test.setTimeout(120_000); + const warnings: string[] = []; + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'warning' && /pivot.*not found/i.test(message.text())) { + warnings.push(message.text()); + } + }); + page.on('pageerror', (error) => errors.push(error.message)); + if (transition === 'fallback') { + await page.addInitScript(() => { + Object.defineProperty(document, 'startViewTransition', { value: undefined }); + }); + } + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + const documentMarker = await page.evaluate(() => { + const marker = crypto.randomUUID(); + Object.defineProperty(window, '__pivotNavigationMarker', { value: marker }); + return marker; + }); + const selector = page.locator('#pivot-selector-aspire-lang'); + const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]'); + const navigate = async (action: () => Promise) => { + await page.evaluate(() => { + Reflect.set(window, '__pivotPageLoaded', false); + document.addEventListener('astro:page-load', () => { + Reflect.set(window, '__pivotPageLoaded', true); + }, { once: true }); + }); + await action(); + await expect.poll(() => page.evaluate(() => Reflect.get(window, '__pivotPageLoaded'))).toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + }; + + for (let visit = 0; visit < 3; visit++) { + await navigate(() => page.locator('a[href="/get-started/first-app/"]:visible').first().click()); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await selector.getByRole('button', { name: 'C#', exact: true }).click(); + await expect(page).toHaveURL(/\/get-started\/first-app\/\?aspire-lang=csharp$/); + await expect + .poll(() => page.evaluate(() => history.state)) + .toMatchObject({ + index: expect.any(Number), + }); + await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' })); + await expect(root).toHaveClass(/floating/); + await expect(root).toHaveClass(/collapsed/); + await root.getByRole('button', { name: 'Expand selector', exact: true }).click(); + await expect(root).not.toHaveClass(/collapsed/); + + // Leave while the old selector still owns its five-second expansion timer. + await navigate(() => page.locator('header a[href="/"]:visible').click()); + await expect(page).toHaveURL(url => url.pathname === '/'); + await expect(root).toHaveCount(0); + await expect(page.locator('.pivot-placeholder')).toHaveCount(0); + await navigate(() => page.goBack()); + await expect(selector).toHaveAttribute('data-pivot-initialized', 'true'); + await expect(selector.locator('[data-pivot-option="csharp"]')).toHaveClass(/active/); + await expect + .poll(() => page.evaluate(() => history.state)) + .toMatchObject({ + index: expect.any(Number), + }); + await navigate(() => page.goForward()); + await expect(page).toHaveURL(url => url.pathname === '/'); + await expect(root).toHaveCount(0); + await page.evaluate(() => { + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + }); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__pivotNavigationMarker'))) + .toBe(documentMarker); + } + expect(warnings).toEqual([]); + expect(errors).toEqual([]); + }); +} diff --git a/src/frontend/tests/e2e/route-style-navigation.spec.ts b/src/frontend/tests/e2e/route-style-navigation.spec.ts new file mode 100644 index 000000000..fafbfcd46 --- /dev/null +++ b/src/frontend/tests/e2e/route-style-navigation.spec.ts @@ -0,0 +1,313 @@ +import { expect, test, type Page } from '@playwright/test'; + +const home = '/'; +const docs = '/docs/'; +const api = (language: string) => `/reference/api/${language}/`; +const redis = (language: string) => `${api(language)}aspire.hosting.redis/redisresource/`; +const routes = [home, docs, api('csharp'), api('typescript'), redis('csharp'), redis('typescript')]; + +async function markNavigation(page: Page) { + await page.evaluate(() => { + Reflect.set(window, '__routeStyleLoaded', false); + document.addEventListener( + 'astro:page-load', + () => { + Reflect.set(window, '__routeStyleLoaded', true); + }, + { once: true } + ); + }); +} + +async function settled(page: Page) { + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__routeStyleLoaded')), { + timeout: 30_000, + }) + .toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + expect(await page.evaluate(() => Reflect.get(window, '__routeStyleSession'))).toBe(true); +} + +async function navigate(page: Page, href: string) { + await markNavigation(page); + await page.evaluate((destination) => { + const anchor = document.createElement('a'); + anchor.id = 'route-style-link'; + anchor.href = destination; + anchor.textContent = 'Navigate'; + anchor.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + document.body.append(anchor); + }, href); + await page.locator('#route-style-link').click({ force: true }); + const destination = new URL(href, page.url()); + await expect(page).toHaveURL( + (url) => + url.pathname === destination.pathname && + [...destination.searchParams].every(([key, value]) => url.searchParams.get(key) === value), + { timeout: 30_000 } + ); + await settled(page); +} + +async function appearance(page: Page) { + await expect(page.locator('main h1')).toHaveCount(1); + await page.evaluate(async () => { + await document.fonts.ready; + window.scrollTo({ top: 0, behavior: 'instant' }); + }); + return page.evaluate(async () => { + const selectors = [ + 'html', + 'body', + 'header.header', + 'header .header', + 'header .title-wrapper', + 'header .right-group', + 'header .right-group-mobile', + 'main h1', + 'main > .content-panel', + '.sl-markdown-content', + '.home-hero-story', + '.home-hero-sticky', + '.home-hero-copy', + '.home-hero-summary', + '.home-hero-actions', + '.home-hero-product', + '.api-hero', + '.api-hero-summary', + '.api-search-input', + '.api-kind-badge', + '.topics-sidebar', + ]; + const properties = [ + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'letter-spacing', + 'color', + 'background-color', + 'display', + 'position', + 'padding', + 'margin-top', + 'margin-bottom', + 'width', + 'max-width', + 'grid-template-columns', + 'gap', + 'overflow-x', + 'word-break', + 'overflow-wrap', + ]; + const styles = Object.fromEntries( + selectors.map((selector) => { + const element = document.querySelector(selector); + if (!element) return [selector, null]; + element.getBoundingClientRect(); + const computed = getComputedStyle(element); + return [ + selector, + Object.fromEntries( + properties.map((property) => [property, computed.getPropertyValue(property)]) + ), + ]; + }) + ); + const root = document.documentElement; + const rootAttributes = Object.fromEntries( + [ + 'lang', + 'dir', + 'data-theme', + 'data-has-sidebar', + 'data-has-hero', + 'data-apphost-lang', + 'data-home-js', + 'data-aspire-environment-state', + 'data-sidebar-collapsed', + 'data-topic-sidebar-collapsed', + 'data-api-sidebar-ready', + 'data-topic-sidebar-ready', + ].map((name) => [name, root.getAttribute(name)]) + ); + return { + styles, + rootAttributes, + rootClass: root.className, + stylesheets: Array.from(document.head.querySelectorAll('link[rel="stylesheet"]'), (link) => + link.getAttribute('href') + ).sort(), + bodyClass: document.body.className, + bodyStyle: document.body.getAttribute('style'), + apiTitle: document.querySelector('main h1')?.classList.contains('api-page-title'), + searchCount: document.querySelectorAll('site-search').length, + actionsCount: document.querySelectorAll('.actions-container').length, + canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href'), + canonicalCount: document.querySelectorAll('link[rel="canonical"]').length, + titleCount: document.head.querySelectorAll('title').length, + }; + }); +} + +for (const theme of ['light', 'dark']) { + test.describe(`${theme} route styles`, () => { + test.beforeEach(async ({ page, isMobile }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('**/scripts/analytics/*.js', (route) => + route.fulfill({ contentType: 'application/javascript', body: '' }) + ); + await page.route('**/wcp-consent.js', (route) => + route.fulfill({ contentType: 'application/javascript', body: '' }) + ); + await page.addInitScript( + ({ theme, fallback }) => { + localStorage.setItem('starlight-theme', theme); + localStorage.setItem('aspireConsentRequired', 'false'); + if (fallback) + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: undefined, + }); + }, + { theme, fallback: isMobile } + ); + }); + + test('fresh rendering matches API/search/Home, docs/Home, cross-language and history swaps', async ({ + page, + }) => { + test.setTimeout(180000); + const baseline = new Map>>(); + for (const route of routes) { + await page.goto(route); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + baseline.set(route, await appearance(page)); + } + const matchesFresh = async (route: string) => { + await expect + .poll(() => appearance(page), { + message: `${route} must match its fresh ${theme} rendering after a client swap`, + }) + .toEqual(baseline.get(route)); + }; + await page.goto(api('csharp')); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + await page.locator('#api-search-input').fill('RedisResource'); + const result = page.locator(`#api-search-results a[href="${redis('csharp')}"]`).first(); + await expect(result).toBeVisible(); + await markNavigation(page); + await result.click(); + await settled(page); + await matchesFresh(redis('csharp')); + await navigate(page, home); + await matchesFresh(home); + for (const route of [ + docs, + home, + api('csharp'), + api('typescript'), + redis('typescript'), + redis('csharp'), + home, + ]) { + await navigate(page, route); + await matchesFresh(route); + } + await markNavigation(page); + await page.goBack(); + await settled(page); + await matchesFresh(redis('csharp')); + await markNavigation(page); + await page.goForward(); + await settled(page); + await matchesFresh(home); + }); + + test('restores saved language and route-specific collapsed sidebars, with URL language taking precedence', async ({ + page, + }) => { + test.setTimeout(120000); + await page.addInitScript(() => { + localStorage.setItem('aspire-lang', 'csharp'); + localStorage.setItem('api-sidebar-collapsed', '1'); + localStorage.setItem('topic-sidebar-collapsed', '1'); + }); + await page.goto(api('csharp')); + await expect(page.locator('html')).toHaveAttribute('data-sidebar-collapsed', ''); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + for (const route of [home, '/get-started/app-host/', api('typescript'), api('csharp')]) { + await navigate(page, route); + const isApi = route.startsWith('/reference/api/'); + const isDoc = route === '/get-started/app-host/'; + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'csharp'); + await expect(page.locator('html[data-sidebar-collapsed]')).toHaveCount(isApi ? 1 : 0); + await expect(page.locator('html[data-topic-sidebar-collapsed]')).toHaveCount(isDoc ? 1 : 0); + if (isApi || isDoc) { + const prefix = isApi ? 'sidebar' : 'topic-sidebar'; + await expect(page.locator(`#${prefix}-expand-btn`)).toHaveAttribute( + 'aria-expanded', + 'false' + ); + await expect(page.locator(`#${prefix}-collapse-btn`)).toHaveAttribute( + 'aria-hidden', + 'true' + ); + } + } + await navigate(page, '/get-started/app-host/?aspire-lang=typescript'); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + await navigate(page, home); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + }); + + test('header install affordance retains responsive visibility and dialog layout after swaps', async ({ + page, + isMobile, + }) => { + test.setTimeout(120000); + await page.goto(home); + await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true)); + const dialog = page.locator('#install-cli-modal'); + const trigger = page.locator('header [data-open-install-modal]:visible'); + if (page.viewportSize()!.width < 800) { + // The compact header intentionally omits the install action. + for (const route of [api('csharp'), docs, home]) { + await expect(trigger).toHaveCount(0); + await expect(dialog).not.toBeVisible(); + await navigate(page, route); + } + await expect(trigger).toHaveCount(0); + return; + } + const openAndMeasure = async () => { + if (isMobile) await trigger.tap(); + else await trigger.click(); + await expect(page.locator('dialog[open]')).toHaveCount(1); + await expect(dialog).toBeVisible(); + return dialog.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + color: style.color, + background: style.backgroundColor, + font: style.font, + width: rect.width, + fits: rect.left >= 0 && rect.right <= window.innerWidth, + }; + }); + }; + const baseline = await openAndMeasure(); + expect(baseline.fits).toBe(true); + for (const route of [api('csharp'), docs, home]) { + const close = dialog.getByRole('button', { name: 'Close modal', exact: true }); + if (isMobile) await close.tap(); + else await close.click(); + await expect(dialog).not.toBeVisible(); + await navigate(page, route); + expect(await openAndMeasure()).toEqual(baseline); + } + }); + }); +} diff --git a/src/frontend/tests/e2e/site-search.spec.ts b/src/frontend/tests/e2e/site-search.spec.ts index 46c1a7281..d7c4c9c17 100644 --- a/src/frontend/tests/e2e/site-search.spec.ts +++ b/src/frontend/tests/e2e/site-search.spec.ts @@ -146,6 +146,38 @@ test.describe('site search dialog', () => { } }); + test('API search result navigation restores homepage hero typography', async ({ page }) => { + await page.setViewportSize({ width: 2000, height: 1001 }); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + + const hero = page.getByRole('heading', { + level: 1, + name: 'Compose distributed apps in code.', + }); + const expectedFontSize = await hero.evaluate((element) => getComputedStyle(element).fontSize); + + await page.goto('/reference/api/csharp/'); + await page.evaluate(() => { + const result = document.createElement('a'); + result.className = 'pagefind-ui__result-link'; + result.href = '/'; + result.textContent = 'Home'; + result.style.cssText = + 'position:fixed;inset-block-start:0;inset-inline-start:0;z-index:2147483647'; + document.body.append(result); + }); + + await navigateClient(page, () => + page.locator('.pagefind-ui__result-link', { hasText: 'Home' }).click() + ); + await expect(page).toHaveURL((url) => url.pathname === '/'); + await expect(hero).not.toHaveClass(/api-page-title/); + await expect.poll(() => hero.evaluate((element) => getComputedStyle(element).fontSize)).toBe( + expectedFontSize + ); + }); + test('navigation during the lazy UI import mounts only the current search instance', async ({ page, }) => { diff --git a/src/frontend/tests/e2e/site-ui-navigation.spec.ts b/src/frontend/tests/e2e/site-ui-navigation.spec.ts new file mode 100644 index 000000000..e4ec9bd73 --- /dev/null +++ b/src/frontend/tests/e2e/site-ui-navigation.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from '@playwright/test'; +import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers'; + +async function navigateClient(page: Page, href: string) { + await page.evaluate((destination) => { + Reflect.set(window, '__dependencyPageLoaded', false); + document.addEventListener( + 'astro:page-load', + () => { + Reflect.set(window, '__dependencyPageLoaded', true); + }, + { once: true } + ); + const link = document.createElement('a'); + link.id = 'dependency-navigation-link'; + link.href = destination; + link.textContent = 'Navigate to dependency test page'; + link.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647'; + document.body.append(link); + }, href); + await page.locator('#dependency-navigation-link').click(); + await expect + .poll(() => page.evaluate(() => Reflect.get(window, '__dependencyPageLoaded')), { + timeout: 30000, + }) + .toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true); +} + +for (const fallback of [false, true]) { + test.describe(fallback ? 'site UI swap fallback' : 'site UI native transitions', () => { + test.beforeEach(async ({ page, isMobile }) => { + test.skip( + isMobile && !fallback, + 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.' + ); + // These runtime tests must never submit analytics events. + await page.route('**/scripts/analytics/*.js', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: '', + }) + ); + await page.addInitScript((swap) => { + localStorage.setItem('starlight-theme', 'light'); + if (swap) { + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: undefined, + }); + } + const renders = new WeakMap(); + new MutationObserver((mutations) => { + for (const { target } of mutations) { + if (!(target instanceof Element) || !target.matches('pre.mermaid[data-processed]')) + continue; + const count = (renders.get(target) ?? 0) + 1; + renders.set(target, count); + target.setAttribute('data-test-render-count', String(count)); + } + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ['data-processed'], + }); + }, fallback); + }); + + test('renders two diagrams once per page/theme across repeat visits and history', async ({ + page, + }) => { + test.setTimeout(120000); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (/\[(?:astro-)?mermaid\]/.test(message.text())) errors.push(message.text()); + }); + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => Reflect.set(window, '__dependencySession', true)); + const diagrams = page.locator('pre.mermaid'); + const expectRenders = async (count: number) => { + await expect(diagrams).toHaveCount(2); + for (const diagram of await diagrams.all()) { + await expect(diagram.locator(':scope > svg')).toHaveCount(1, { timeout: 30000 }); + await expect(diagram).toHaveAttribute('data-test-render-count', String(count)); + } + }; + for (let visit = 0; visit < 2; visit++) { + await navigateClient(page, '/get-started/first-app/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await expectRenders(1); + await page.evaluate(() => { + const html = document.documentElement; + const theme = html.getAttribute('data-theme')!; + html.removeAttribute('data-theme'); + html.setAttribute('data-theme', theme); + }); + await expectRenders(1); + for (const [theme, count] of [ + ['dark', 2], + ['light', 3], + ] as const) { + await page.locator(`#footer-theme-toggle [data-theme-option="${theme}"]`).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + await expectRenders(count); + } + await navigateClient(page, '/docs/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + } + await page.goBack(); + await expectRenders(1); + expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true); + expect(errors).toEqual([]); + }); + + test('activates scroll once per mouse, touch, Enter and Space after repeated swaps', async ({ + page, + isMobile, + }) => { + test.setTimeout(120000); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + await page.evaluate(() => { + Reflect.set(window, '__dependencySession', true); + const scrolls: ScrollToOptions[] = []; + Reflect.set(window, '__dependencyScrolls', scrolls); + const scrollTo = window.scrollTo.bind(window); + window.scrollTo = (options?: ScrollToOptions | number, y?: number) => { + if (typeof options === 'number') scrollTo(options, y ?? 0); + else { + if (options?.top === 0) scrolls.push(options); + scrollTo(options); + } + }; + }); + for (let visit = 0; visit < 2; visit++) { + await navigateClient(page, '/get-started/app-host/'); + const button = page.locator('#scroll-to-top-button'); + await expect(button).toHaveCount(1); + for (const activation of ['pointer', 'Enter', 'Space']) { + await page.evaluate(() => window.scrollTo({ top: 800, behavior: 'instant' })); + await expect(button).toBeVisible(); + await page.evaluate(() => + Reflect.set( + window, + '__dependencyScrollCountBefore', + Reflect.get(window, '__dependencyScrolls').length + ) + ); + if (activation === 'pointer') { + if (isMobile) await button.tap(); + else await button.click(); + } else { + await button.focus(); + await page.keyboard.press(activation); + } + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + expect( + await page.evaluate(() => { + const scrolls: ScrollToOptions[] = Reflect.get(window, '__dependencyScrolls'); + const before: number = Reflect.get(window, '__dependencyScrollCountBefore'); + return scrolls.slice(before); + }) + ).toEqual([{ top: 0, behavior: 'auto' }]); + } + await navigateClient(page, '/docs/'); + } + }); + }); +} diff --git a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts index b28b01225..ef3c1804b 100644 --- a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts +++ b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { expect, test } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import { expect, test, vi } from 'vitest'; const testsDir = path.dirname(fileURLToPath(import.meta.url)); const frontendRoot = path.resolve(testsDir, '..', '..'); @@ -41,3 +42,104 @@ test('analytics asset files contain javascript bootstrap code', () => { expect(oneDsScript.trimStart().startsWith('<')).toBe(false); expect(trackScript.trimStart().startsWith('<')).toBe(false); }); + +function analyticsFixture(origin = 'https://aspire.dev') { + type TrackedElement = { + tagName: string; + href?: string; + textContent: string; + attributes: { name: string; value: string }[]; + getAttribute: (name: string) => string | null; + }; + type TrackEvent = { target: { closest: (selector: string) => TrackedElement | null } }; + const initialize = vi.fn<(config: unknown, extensions: unknown[]) => void>(); + const capturePageAction = vi.fn<(target: TrackedElement, overrides: Record) => void>(); + const addEventListener = vi.fn<(type: string, listener: (event: TrackEvent) => void) => void>(); + const console = { debug: vi.fn(), warn: vi.fn() }; + const context = { + location: { origin }, + window: {}, + document: { addEventListener }, + console, + oneDS: { + ApplicationInsights: class { + initialize = initialize; + capturePageAction = capturePageAction; + }, + }, + }; + const bootstrap = () => { runInNewContext(read('public/scripts/analytics/1ds.js'), context); }; + const tracking = () => { runInNewContext(read('public/scripts/analytics/track.js'), context); }; + return { initialize, capturePageAction, addEventListener, console, bootstrap, tracking }; +} + +test('analytics excludes unload without disabling other lifecycle or click capture', () => { + const fixture = analyticsFixture(); + fixture.bootstrap(); + fixture.bootstrap(); + expect(fixture.initialize).toHaveBeenCalledOnce(); + expect(fixture.initialize.mock.calls[0][0]).toMatchObject({ + disablePageUnloadEvents: ['unload'], + webAnalyticsConfiguration: { + autoCapture: { onUnload: true, click: true, pageView: true }, + }, + }); + expect(fixture.console.debug).not.toHaveBeenCalled(); + expect(fixture.console.warn).not.toHaveBeenCalled(); +}); + +test('analytics and tracking remain inactive on other origins', () => { + const fixture = analyticsFixture('http://localhost:4321'); + fixture.bootstrap(); + fixture.tracking(); + expect(fixture.initialize).not.toHaveBeenCalled(); + expect(fixture.addEventListener).not.toHaveBeenCalled(); + expect(fixture.console.debug).not.toHaveBeenCalled(); +}); + +test('tracking binds once, forwards nested clicks and stays quiet on success', () => { + const fixture = analyticsFixture(); + fixture.tracking(); + expect(fixture.addEventListener).not.toHaveBeenCalled(); + fixture.bootstrap(); + fixture.tracking(); + fixture.tracking(); + expect(fixture.addEventListener).toHaveBeenCalledOnce(); + const [event, listener] = fixture.addEventListener.mock.calls[0]; + expect(event).toBe('click'); + const link = { + tagName: 'A', + href: 'https://aspire.dev/docs/', + textContent: ' Docs ', + attributes: [{ name: 'data-track-source-name', value: 'header' }], + getAttribute: () => 'docs-link', + }; + listener({ target: { closest: () => link } }); + expect(fixture.capturePageAction).toHaveBeenCalledExactlyOnceWith(link, { + name: 'docs-link', sourceName: 'header', href: link.href, text: 'Docs', + }); + expect(fixture.console.debug).not.toHaveBeenCalled(); + expect(fixture.console.warn).not.toHaveBeenCalled(); +}); + +test('initialization and capture failures remain visible', () => { + const initialization = analyticsFixture(); + initialization.initialize.mockImplementation(() => { throw new Error('initialization failed'); }); + initialization.bootstrap(); + expect(initialization.console.warn).toHaveBeenCalledWith( + '[1ds] Failed to initialize Application Insights:', expect.any(Error), + ); + + const tracking = analyticsFixture(); + tracking.bootstrap(); + tracking.tracking(); + tracking.capturePageAction.mockImplementation(() => { throw new Error('capture failed'); }); + tracking.addEventListener.mock.calls[0][1]({ + target: { closest: () => ({ + tagName: 'BUTTON', textContent: '', attributes: [], getAttribute: () => 'button', + }) }, + }); + expect(tracking.console.warn).toHaveBeenCalledWith( + '[track] Failed to track event:', expect.any(Error), + ); +}); diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 2bafedbd4..0be72ca78 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -41,6 +41,7 @@ import ReleaseCommunity from '@components/ReleaseCommunity.astro'; import SampleCard from '@components/SampleCard.astro'; import SampleDetail from '@components/SampleDetail.astro'; import SampleGrid from '@components/SampleGrid.astro'; +import ScrollToTop from '@components/ScrollToTop.astro'; import SessionCard from '@components/SessionCard.astro'; import SessionGrid from '@components/SessionGrid.astro'; import SimpleAppHostCode from '@components/SimpleAppHostCode.astro'; @@ -82,6 +83,20 @@ type BasicRenderCase = { requestUrl?: string; }; +it.each([ + ['en', 'Scroll to top'], ['fr', 'Retour en haut'], ['pt-BR', 'Voltar ao topo'], + ['zh-CN', '回到顶部'], ['es-ES', 'Ir arriba'], ['unknown', 'Scroll to top'], +])('ScrollToTop renders the existing label for %s', async (lang, label) => { + const route = { + lang, editUrl: '', + entry: { id: 'docs/test', slug: 'docs/test', filePath: '', data: {} }, + }; + const html = await renderComponent(ScrollToTop, { locals: { starlightRoute: route } }); + expect(html).toContain(`aria-label="${label}"`); + expect(html).toContain('id="scroll-to-top-button"'); + expect(html).toContain('type="button"'); +}); + const statementPlayerTranslations = { da: daTranslations.landing.statementPlayer, de: deTranslations.landing.statementPlayer, diff --git a/src/frontend/tests/unit/deployment-guard.vitest.test.ts b/src/frontend/tests/unit/deployment-guard.vitest.test.ts new file mode 100644 index 000000000..4b099338a --- /dev/null +++ b/src/frontend/tests/unit/deployment-guard.vitest.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, test, vi } from 'vitest'; + +const source = readFileSync( + new URL('../../src/scripts/deployment-guard.ts', import.meta.url), + 'utf8', +); +const script = transpileModule(source, { + compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 }, +}).outputText; + +function fixture(running: string | null, incoming: string | null) { + const events = new EventTarget(); + const meta = running === null ? null : { content: running }; + const warn = vi.fn(); + runInNewContext(script, { + exports: {}, + console: { warn }, + document: { + querySelector: () => meta, + addEventListener: events.addEventListener.bind(events), + }, + }); + const controller = new AbortController(); + const load = vi.fn(async () => {}); + const event = Object.assign(new Event('astro:before-preparation', { cancelable: true }), { + loader: load, + signal: controller.signal, + newDocument: { querySelector: () => incoming === null ? null : { content: incoming } }, + }); + events.dispatchEvent(event); + return { event, load, controller, warn, meta }; +} + +describe('deployment boundary navigation', () => { + test.each([ + ['same deployment', 'build-a', 'build-a', false], + ['new deployment', 'build-a', 'build-b', true], + ['missing destination metadata', 'build-a', null, true], + ['missing running metadata', null, 'build-b', true], + ['unversioned local documents', null, null, false], + ['trimmed metadata', ' build-a ', 'build-a', false], + ])('%s', async (_label, running, incoming, reload) => { + const { event, load, warn } = fixture(running, incoming); + expect(event.defaultPrevented).toBe(false); + await event.loader(); + expect(load).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(reload); + expect(warn).toHaveBeenCalledTimes(reload && (!running || !incoming) ? 1 : 0); + }); + + test('retains the running deployment instead of reading mutated page metadata', async () => { + const { event, meta } = fixture('build-a', 'build-b'); + meta!.content = 'build-b'; + await event.loader(); + expect(event.defaultPrevented).toBe(true); + }); + + test('respects navigation cancellation and aborts during loading', async () => { + const canceled = fixture('build-a', null); + canceled.event.preventDefault(); + await canceled.event.loader(); + expect(canceled.warn).not.toHaveBeenCalled(); + + const aborted = fixture('build-a', 'build-b'); + aborted.load.mockImplementation(() => { + aborted.controller.abort(); + return Promise.resolve(); + }); + await aborted.event.loader(); + expect(aborted.event.defaultPrevented).toBe(false); + }); + + test('does not hide loader failures', async () => { + const { event, load } = fixture('build-a', 'build-b'); + load.mockRejectedValue(new Error('navigation failed')); + await expect(event.loader()).rejects.toThrow('navigation failed'); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/src/frontend/tests/unit/navigation-preferences.vitest.test.ts b/src/frontend/tests/unit/navigation-preferences.vitest.test.ts new file mode 100644 index 000000000..fa60377f0 --- /dev/null +++ b/src/frontend/tests/unit/navigation-preferences.vitest.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { expect, test, vi } from 'vitest'; + +const head = readFileSync( + new URL('../../src/components/starlight/Head.astro', import.meta.url), + 'utf8' +); +const script = [...head.matchAll(/')).toBe('const ready = true;'); +}); + +class Element extends EventTarget { + id = ''; + lang = 'en'; + type = ''; + ariaLabel = ''; + textContent = ''; + innerHTML = ''; + scrollHeight = 2000; + style: Record = {}; + attributes = new Map(); + dataset: Record = {}; + classes = new Set(); + classList = { + add: (...names: string[]) => names.forEach((name) => this.classes.add(name)), + remove: (...names: string[]) => names.forEach((name) => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force = !this.classes.has(name)) => { + if (force) this.classes.add(name); + else this.classes.delete(name); + return force; + }, + }; + parentNode: Element | null = null; + children: Element[] = []; + root = false; + constructor(readonly tagName = 'div') { + super(); + } + get parentElement() { + return this.parentNode; + } + get isConnected(): boolean { + return this.root || Boolean(this.parentNode?.isConnected); + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith('data-')) this.dataset[name.slice(5)] = value; + } + getAttribute(name: string) { + return name.startsWith('data-') + ? (this.dataset[name.slice(5)] ?? null) + : (this.attributes.get(name) ?? null); + } + hasAttribute(name: string) { + return this.getAttribute(name) !== null; + } + removeAttribute(name: string) { + this.attributes.delete(name); + if (name.startsWith('data-')) delete this.dataset[name.slice(5)]; + } + append(child: Element) { + child.remove(); + this.appendChild(child); + } + appendChild(child: T) { + child.parentNode = this; + this.children.push(child); + return child; + } + removeChild(child: Element) { + this.children = this.children.filter((element) => element !== child); + child.parentNode = null; + } + remove() { + this.parentNode?.removeChild(this); + } + contains(element: Element): boolean { + return this === element || this.children.some((child) => child.contains(element)); + } + all(): Element[] { + return this.children.flatMap((child) => [child, ...child.all()]); + } + querySelectorAll(selector: string) { + return this.all().filter((element) => { + if (selector.startsWith('#')) return element.id === selector.slice(1); + if (selector === 'pre.mermaid') { + return element.tagName === 'pre' && element.classList.contains('mermaid'); + } + if (selector.startsWith('.')) return element.classList.contains(selector.slice(1)); + return element.tagName === selector; + }); + } + querySelector(selector: string): Element | null { + return this.querySelectorAll(selector)[0] ?? null; + } +} + +class Document extends EventTarget { + documentElement = new Element('html'); + head = this.documentElement.appendChild(new Element('head')); + body = this.documentElement.appendChild(new Element('body')); + readyState = 'complete'; + constructor() { + super(); + this.documentElement.root = true; + this.documentElement.setAttribute('data-theme', 'light'); + } + createElement(tag: string) { + return new Element(tag); + } + querySelectorAll(selector: string) { + return this.documentElement.querySelectorAll(selector); + } + querySelector(selector: string) { + return this.documentElement.querySelector(selector); + } + getElementById(id: string) { + return this.querySelector(`#${id}`); + } + emit(name: string) { + this.dispatchEvent(new Event(name)); + } + replaceBody(sources: string[] = []) { + this.body.remove(); + this.body = this.documentElement.appendChild(new Element('body')); + return sources.map((source) => { + const diagram = this.body.appendChild(new Element('pre')); + diagram.classList.add('mermaid'); + diagram.textContent = source; + return diagram; + }); + } +} + +function observers() { + const instances: Observer[] = []; + class Observer { + targets = new Set(); + constructor(readonly callback: () => void) { + instances.push(this); + } + observe(target: Element) { + this.targets.add(target); + } + disconnect() { + this.targets.clear(); + } + } + return { + Observer, + instances, + notify: (target: Element) => { + instances + .filter((observer) => observer.targets.has(target)) + .forEach((observer) => observer.callback()); + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +async function mermaidRuntime({ + sources = ['graph TD; A-->B', 'graph TD; C-->D'], + loading = false, +} = {}) { + const { iconPacks } = await import('../../config/icon-packs.mjs'); + const icons = { prefix: 'test', icons: { app: { body: '' } } }; + const fetch = vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify(icons)))); + const document = new Document(); + const diagrams = document.replaceBody(sources); + if (loading) document.readyState = 'loading'; + const mutation = observers(); + const logger = { log: vi.fn(), error: vi.fn() }; + const mermaid = { + initialize: vi.fn<(config: { theme: string }) => void>(), + registerIconPacks: vi.fn<(packs: AsyncIconLoader[]) => void>(), + render: vi + .fn<(id: string, source: string) => Promise<{ svg: string }>>() + .mockImplementation((_id, source) => Promise.resolve({ svg: `${source}` })), + }; + const imported = deferred<{ default: typeof mermaid }>(); + const importMermaid = vi.fn(() => imported.promise); + // Replace only the module boundary; execute the actual site runtime. + runInNewContext(compile(mermaidSource).replace("import('mermaid')", 'importMermaid()'), { + document, + MutationObserver: mutation.Observer, + console: logger, + queueMicrotask, + importMermaid, + iconPacks, + fetch, + Error, + }); + if (!loading) document.emit('astro:page-load'); + return { + document, + diagrams, + mutation, + mermaid, + imported, + importMermaid, + logger, + icons, + iconPacks, + fetch, + theme(value: string, target = document.documentElement) { + target.setAttribute('data-theme', value); + mutation.notify(target); + }, + swap(sources: string[]) { + document.emit('astro:before-swap'); + const diagrams = document.replaceBody(sources); + document.emit('astro:after-swap'); + document.emit('astro:page-load'); + return diagrams; + }, + }; +} + +describe('site-owned Mermaid runtime', () => { + it('coalesces readiness, page-load and same-theme notifications before and during rendering', async () => { + const fixture = await mermaidRuntime({ loading: true }); + const { document, mermaid, imported, diagrams, importMermaid, logger } = fixture; + expect(importMermaid).not.toHaveBeenCalled(); + document.emit('DOMContentLoaded'); + document.emit('astro:page-load'); + fixture.theme('light'); + await flush(); + expect(importMermaid).toHaveBeenCalledOnce(); + const render = deferred<{ svg: string }>(); + mermaid.render.mockReturnValueOnce(render.promise); + imported.resolve({ default: mermaid }); + await flush(); + document.emit('astro:after-swap'); + document.emit('astro:page-load'); + fixture.theme('light'); + await flush(); + expect(mermaid.render).toHaveBeenCalledOnce(); + render.resolve({ svg: 'first' }); + await flush(); + expect(mermaid.render).toHaveBeenCalledTimes(2); + expect(mermaid.initialize).toHaveBeenCalledTimes(1); + expect(diagrams.map((diagram) => diagram.getAttribute('data-processed'))).toEqual([ + 'true', + 'true', + ]); + expect(mermaid.registerIconPacks).toHaveBeenCalledOnce(); + expect(mermaid.registerIconPacks.mock.calls[0][0].map((pack) => pack.name)).toEqual( + fixture.iconPacks.map((pack) => pack.name) + ); + expect(logger.log).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('keeps imports lazy on empty pages and renders two diagrams once on each return visit', async () => { + const fixture = await mermaidRuntime({ sources: [] }); + await flush(); + expect(fixture.importMermaid).not.toHaveBeenCalled(); + fixture.imported.resolve({ default: fixture.mermaid }); + for (let visit = 1; visit <= 3; visit++) { + fixture.swap(['graph TD; A-->B', 'graph TD; C-->D']); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(visit * 2); + fixture.swap([]); + await flush(); + } + expect(fixture.importMermaid).toHaveBeenCalledOnce(); + }); + + it('ignores equivalent attribute churn and rerenders only for effective theme changes', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + fixture.document.documentElement.removeAttribute('data-theme'); + fixture.theme('light'); + fixture.theme('dark', fixture.document.body); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(4); + expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([ + 'default', + 'dark', + ]); + expect(fixture.mermaid.render.mock.calls.map(([, source]) => source)).toEqual([ + 'graph TD; A-->B', + 'graph TD; C-->D', + 'graph TD; A-->B', + 'graph TD; C-->D', + ]); + }); + + it('serializes global theme configuration and discards superseded in-flight SVGs', async () => { + const fixture = await mermaidRuntime({ sources: ['graph TD; A-->B'] }); + const render = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(render.promise); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + fixture.theme('dark'); + fixture.theme('light'); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.initialize).toHaveBeenCalledOnce(); + expect(fixture.mermaid.render).toHaveBeenCalledOnce(); + const latest = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(latest.promise); + render.resolve({ svg: 'obsolete light' }); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe(''); + expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([ + 'default', + 'dark', + ]); + latest.resolve({ svg: 'latest dark' }); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe('latest dark'); + }); + + it('abandons imports owned by a replaced page and observes the new body, not the old body', async () => { + const fixture = await mermaidRuntime(); + await flush(); + const oldBody = fixture.document.body; + const next = fixture.swap(['graph TD; New-->Page']); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + 'graph TD; New-->Page' + ); + expect(fixture.diagrams.every((diagram) => diagram.innerHTML === '')).toBe(true); + expect(fixture.mutation.instances[0].targets.has(oldBody)).toBe(false); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + expect(next[0].getAttribute('data-processed')).toBe('true'); + }); + + it.each(['resolve', 'reject'] as const)( + 'does not write stale %s completions after a swap', + async (outcome) => { + const fixture = await mermaidRuntime(); + const render = deferred<{ svg: string }>(); + fixture.mermaid.render.mockReturnValueOnce(render.promise); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + const next = fixture.swap(['graph TD; New-->Page']); + await flush(); + expect(fixture.mermaid.initialize).toHaveBeenCalledOnce(); + if (outcome === 'resolve') render.resolve({ svg: 'stale' }); + else render.reject(new Error('obsolete render failed')); + await flush(); + expect(fixture.diagrams[0].innerHTML).toBe(''); + expect(fixture.diagrams[0].children).toHaveLength(0); + expect(fixture.diagrams[0].hasAttribute('data-processed')).toBe(false); + expect(next[0].innerHTML).toContain('New-->Page'); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + expect(fixture.logger.error).toHaveBeenCalledTimes(outcome === 'reject' ? 1 : 0); + } + ); + + it('keeps current work alive when navigation preparation is canceled', async () => { + const fixture = await mermaidRuntime(); + fixture.document.emit('astro:before-preparation'); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(4); + }); + + it('preserves visible escaped errors and source without retrying an unchanged failed diagram', async () => { + const fixture = await mermaidRuntime({ sources: ['invalid '] }); + fixture.mermaid.render.mockRejectedValueOnce(new Error('')); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + const [diagram] = fixture.diagrams; + expect(fixture.logger.error).toHaveBeenCalledOnce(); + expect(diagram.getAttribute('data-diagram')).toBe('invalid '); + expect(diagram.textContent).toBe('Unable to render diagram: '); + expect(diagram.classList.contains('mermaid-error')).toBe(true); + expect(diagram.innerHTML).toBe(''); + fixture.document.emit('astro:page-load'); + await flush(); + expect(fixture.mermaid.render).toHaveBeenCalledOnce(); + fixture.theme('dark'); + await flush(); + expect(fixture.mermaid.render.mock.calls[1][1]).toBe('invalid '); + }); + + it('logs import failures, clears the failed import promise and retries on the next page load', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.reject(new Error('import failed')); + await flush(); + expect(fixture.logger.error).toHaveBeenCalled(); + fixture.importMermaid.mockResolvedValueOnce({ default: fixture.mermaid }); + fixture.document.emit('astro:page-load'); + await flush(); + expect(fixture.importMermaid).toHaveBeenCalledTimes(2); + expect(fixture.mermaid.render).toHaveBeenCalledTimes(2); + }); + + it('loads configured icon packs lazily and reports failed responses', async () => { + const fixture = await mermaidRuntime(); + fixture.imported.resolve({ default: fixture.mermaid }); + await flush(); + expect(fixture.fetch).not.toHaveBeenCalled(); + const pack = fixture.mermaid.registerIconPacks.mock.calls[0][0][0]; + await expect(pack.loader()).resolves.toEqual(fixture.icons); + expect(fixture.fetch).toHaveBeenCalledWith(fixture.iconPacks[0].url); + fixture.fetch.mockResolvedValueOnce(new Response('', { status: 503 })); + await expect(pack.loader()).rejects.toThrow('HTTP 503'); + }); +}); + +function scrollRuntime({ reducedMotion = false } = {}) { + vi.useFakeTimers(); + const document = new Document(); + const window = Object.assign(new EventTarget(), { + scrollY: 500, + innerHeight: 900, + innerWidth: 1440, + outerWidth: 1440, + scrollTo: vi.fn(), + matchMedia: () => ({ matches: reducedMotion }), + requestAnimationFrame: (callback: () => void) => setTimeout(callback, 16), + cancelAnimationFrame: clearTimeout, + }); + type ScrollElement = Element & { connectedCallback(): void; disconnectedCallback(): void }; + let Constructor: (new () => ScrollElement) | undefined; + runInNewContext(compile(scrollSource!), { + document, + window, + HTMLElement: Element, + AbortController, + customElements: { + define: (_name: string, value: new () => ScrollElement) => { + Constructor = value; + }, + }, + }); + if (!Constructor) throw new Error('Scroll-to-top custom element was not registered.'); + const root = document.body.appendChild(new Constructor()); + const button = root.appendChild(new Element('button')); + button.id = 'scroll-to-top-button'; + button.type = 'button'; + root.connectedCallback(); + return { document, window, root, button: () => button }; +} + +describe('site-owned scroll-to-top control', () => { + afterEach(() => vi.useRealTimers()); + + it('mounts once per connection without document readiness or keyboard handlers', () => { + const fixture = scrollRuntime(); + fixture.root.connectedCallback(); + fixture.document.emit('DOMContentLoaded'); + fixture.document.emit('astro:page-load'); + expect(vi.getTimerCount()).toBe(1); + vi.runAllTimers(); + const button = fixture.button(); + fixture.document.emit('astro:page-load'); + vi.runAllTimers(); + expect(fixture.button()).toBe(button); + expect(fixture.document.querySelectorAll('#scroll-to-top-button')).toHaveLength(1); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.document, 'DOMContentLoaded')).toHaveLength(0); + }); + + it.each([false, true])( + 'uses only native click activation (reduced motion: %s)', + (reducedMotion) => { + const fixture = scrollRuntime({ reducedMotion }); + vi.runAllTimers(); + const button = fixture.button(); + expect(button.type).toBe('button'); + for (const name of ['touchstart', 'touchend', 'keydown']) { + expect(getEventListeners(button, name)).toHaveLength(0); + } + const click = new Event('click', { cancelable: true }); + button.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + expect(fixture.window.scrollTo).toHaveBeenCalledExactlyOnceWith({ + top: 0, + behavior: reducedMotion ? 'auto' : 'smooth', + }); + expect(scrollComponent).toContain('button:focus-visible'); + } + ); + + it('disposes element/window handlers and pending frames when disconnected', () => { + const fixture = scrollRuntime(); + fixture.root.disconnectedCallback(); + expect(vi.getTimerCount()).toBe(0); + vi.runAllTimers(); + for (let visit = 0; visit < 3; visit++) { + fixture.root.connectedCallback(); + vi.runAllTimers(); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(1); + fixture.window.dispatchEvent(new Event('scroll')); + expect(vi.getTimerCount()).toBe(1); + fixture.root.disconnectedCallback(); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(0); + expect(getEventListeners(fixture.window, 'resize')).toHaveLength(0); + expect(getEventListeners(fixture.button(), 'click')).toHaveLength(0); + } + }); + + it('survives canceled preparation and updates scroll/zoom visibility in one frame', () => { + const fixture = scrollRuntime(); + vi.runAllTimers(); + const button = fixture.button(); + fixture.document.emit('astro:before-preparation'); + expect(fixture.button()).toBe(button); + expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0); + fixture.window.outerWidth = 6000; + fixture.window.dispatchEvent(new Event('resize')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(false); + fixture.window.outerWidth = 1440; + fixture.window.dispatchEvent(new Event('resize')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(true); + fixture.window.dispatchEvent(new Event('scroll')); + fixture.window.scrollY = 0; + fixture.window.dispatchEvent(new Event('scroll')); + vi.runAllTimers(); + expect(button.classList.contains('visible')).toBe(false); + }); +});