From ca35d020b628511811b904ee935f4c25221955f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Sat, 14 Feb 2026 22:26:50 +0100 Subject: [PATCH 1/5] fix: harden app loading, tests, and e2e workflow --- website/package.json | 1 + website/playwright.config.js | 12 +- website/src/__tests__/i18n-dom.test.js | 16 +- website/src/components/anchor-modal.js | 33 ++- website/src/components/anchor-modal.test.js | 4 +- website/src/components/card-grid.js | 55 +++-- website/src/components/doc-page.js | 21 +- website/src/components/doc-page.test.js | 42 ++++ website/src/components/footer.js | 2 +- website/src/i18n.js | 3 - website/src/main.js | 225 ++++++++++---------- website/src/translations/de.json | 2 +- website/src/translations/en.json | 2 +- website/src/utils/data-loader.js | 34 ++- website/src/utils/data-loader.test.js | 48 ++++- website/src/utils/router.test.js | 31 +++ website/src/utils/search-index.js | 79 ++++--- website/src/utils/search-index.test.js | 41 ++++ website/tests/e2e/website.spec.js | 58 ++--- website/vite.config.js | 19 +- 20 files changed, 474 insertions(+), 254 deletions(-) create mode 100644 website/src/components/doc-page.test.js create mode 100644 website/src/utils/router.test.js create mode 100644 website/src/utils/search-index.test.js diff --git a/website/package.json b/website/package.json index acc9e25..960a866 100644 --- a/website/package.json +++ b/website/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:prod": "PLAYWRIGHT_BASE_URL=https://raifdmueller.github.io/Semantic-Anchors/ playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", "test:lighthouse": "lhci autorun", diff --git a/website/playwright.config.js b/website/playwright.config.js index dde8cc4..a499f89 100644 --- a/website/playwright.config.js +++ b/website/playwright.config.js @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test' +const LOCAL_BASE_URL = 'http://127.0.0.1:4173' +const baseURL = process.env.PLAYWRIGHT_BASE_URL || LOCAL_BASE_URL +const useLocalWebServer = !process.env.PLAYWRIGHT_BASE_URL + export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, @@ -8,10 +12,16 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'https://raifdmueller.github.io/Semantic-Anchors/', + baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', }, + webServer: useLocalWebServer ? { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: LOCAL_BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120000, + } : undefined, projects: [ { diff --git a/website/src/__tests__/i18n-dom.test.js b/website/src/__tests__/i18n-dom.test.js index a7447cd..142bf62 100644 --- a/website/src/__tests__/i18n-dom.test.js +++ b/website/src/__tests__/i18n-dom.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { i18n, applyTranslations } from '../i18n.js' describe('applyTranslations (DOM updates)', () => { @@ -15,12 +15,12 @@ describe('applyTranslations (DOM updates)', () => { expect(document.querySelector('h2').textContent).toBe('Explore Semantic Anchors') }) - it('sets innerHTML for elements with data-i18n-html', () => { - document.body.innerHTML = '

' + it('sets text content for footer tagline', () => { + document.body.innerHTML = '

' applyTranslations() - const html = document.querySelector('p').innerHTML - expect(html).toContain('Semantic Anchors') - expect(html).toContain('Shared vocabulary for LLM communication') + const text = document.querySelector('p').textContent + expect(text).toContain('Semantic Anchors') + expect(text).toContain('Shared vocabulary for LLM communication') }) it('sets placeholder for elements with data-i18n-placeholder', () => { @@ -47,11 +47,11 @@ describe('applyTranslations (DOM updates)', () => { document.body.innerHTML = `

-

+

` applyTranslations() expect(document.querySelector('h2').textContent).toBe('Explore Semantic Anchors') expect(document.querySelector('input').placeholder).toBe('Search anchors...') - expect(document.querySelector('p').innerHTML).toContain('Shared vocabulary') + expect(document.querySelector('p').textContent).toContain('Shared vocabulary') }) }) diff --git a/website/src/components/anchor-modal.js b/website/src/components/anchor-modal.js index 4d0e621..1345953 100644 --- a/website/src/components/anchor-modal.js +++ b/website/src/components/anchor-modal.js @@ -1,7 +1,22 @@ -import Asciidoctor from '@asciidoctor/core' import { i18n } from '../i18n.js' -const asciidoctor = Asciidoctor() +let asciidoctor = null + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +async function getAsciidoctor() { + if (asciidoctor) return asciidoctor + const module = await import('@asciidoctor/core') + asciidoctor = module.default() + return asciidoctor +} export function createModal() { // Check if modal already exists @@ -119,8 +134,9 @@ export async function loadAnchorContent(anchorId) { const adocContent = await response.text() // Convert AsciiDoc to HTML - const htmlContent = asciidoctor.convert(adocContent, { - safe: 'safe', + const asciidocEngine = await getAsciidoctor() + const htmlContent = asciidocEngine.convert(adocContent, { + safe: 'secure', attributes: { showtitle: true, sectanchors: true @@ -132,7 +148,7 @@ export async function loadAnchorContent(anchorId) { const title = titleMatch ? titleMatch[1] : anchorId titleEl.textContent = title - contentEl.innerHTML = htmlContent + contentEl.innerHTML = String(htmlContent) // Auto-expand all collapsible sections contentEl.querySelectorAll('details').forEach(details => { @@ -156,12 +172,13 @@ export async function loadAnchorContent(anchorId) { } catch (error) { console.error('Error loading anchor content:', error) titleEl.textContent = 'Error' + const message = error instanceof Error ? error.message : String(error) contentEl.innerHTML = `

Failed to load anchor content

-

${error.message}

+

${escapeHtml(message)}

- Anchor ID: ${anchorId} + Anchor ID: ${escapeHtml(anchorId)}

` @@ -174,7 +191,7 @@ export function showAnchorDetails(anchorId) { modal.dataset.currentAnchor = anchorId } openModal() - loadAnchorContent(anchorId) + return loadAnchorContent(anchorId) } async function shareAnchor(anchorId, title) { diff --git a/website/src/components/anchor-modal.test.js b/website/src/components/anchor-modal.test.js index b25afde..b1a99ed 100644 --- a/website/src/components/anchor-modal.test.js +++ b/website/src/components/anchor-modal.test.js @@ -100,13 +100,13 @@ describe('anchor-modal', () => { delete global.fetch }) - it('should open modal when called', () => { + it('should open modal when called', async () => { global.fetch.mockResolvedValue({ ok: true, text: async () => '= Test Anchor\n\nTest content' }) - showAnchorDetails('test-anchor') + await showAnchorDetails('test-anchor') const modal = document.getElementById('anchor-modal') expect(modal.classList.contains('hidden')).toBe(false) }) diff --git a/website/src/components/card-grid.js b/website/src/components/card-grid.js index 2f77fd1..21d0b1b 100644 --- a/website/src/components/card-grid.js +++ b/website/src/components/card-grid.js @@ -1,6 +1,15 @@ import { i18n } from '../i18n.js' import { search as performFullTextSearch, isIndexReady } from '../utils/search-index.js' +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + /** * Category color palette (matching previous categories) */ @@ -65,10 +74,10 @@ function renderCategorySection(category, allAnchors) { const categoryName = i18n.t(`categories.${category.id}`) || category.name return ` -
+

${icon} - ${categoryName} + ${escapeHtml(categoryName)}

@@ -83,44 +92,43 @@ function renderCategorySection(category, allAnchors) { */ function renderAnchorCard(anchor, categoryColor) { const rolesCount = anchor.roles ? anchor.roles.length : 0 - const tagsPreview = anchor.tags ? anchor.tags.slice(0, 3).join(', ') : '' const githubEditUrl = `https://github.com/LLM-Coding/Semantic-Anchors/edit/main/docs/anchors/${anchor.id}.adoc` const roleText = rolesCount === 1 ? i18n.t('card.roles') : i18n.t('card.rolesPlural') const tagsText = i18n.t('card.tags') const editTitle = i18n.t('card.edit') const copyLinkTitle = i18n.t('card.copyLink') + const safeId = escapeHtml(anchor.id) return `
- ${anchor.proponents ? ` -

${anchor.proponents.slice(0, 2).join(', ')}

+ ${anchor.proponents && anchor.proponents.length > 0 ? ` +

${escapeHtml(anchor.proponents.slice(0, 2).join(', '))}

` : ''}
@@ -180,6 +188,21 @@ export function initCardGrid() { // Click handler using event delegation clickHandler = (e) => { + if (e.target.closest('.anchor-copy-link-btn')) { + const button = e.target.closest('.anchor-copy-link-btn') + const anchorId = button?.dataset.copyLink + if (anchorId) { + e.stopPropagation() + window.copyAnchorLink(anchorId) + } + return + } + + if (e.target.closest('.anchor-edit-btn')) { + e.stopPropagation() + return + } + const card = e.target.closest('.anchor-card') if (card) { const anchorId = card.dataset.anchor diff --git a/website/src/components/doc-page.js b/website/src/components/doc-page.js index 4404e8f..1098ff6 100644 --- a/website/src/components/doc-page.js +++ b/website/src/components/doc-page.js @@ -1,7 +1,13 @@ -import Asciidoctor from '@asciidoctor/core' import { i18n } from '../i18n.js' -const asciidoctor = Asciidoctor() +let asciidoctor = null + +async function getAsciidoctor() { + if (asciidoctor) return asciidoctor + const module = await import('@asciidoctor/core') + asciidoctor = module.default() + return asciidoctor +} /** * Render a documentation page from an AsciiDoc file @@ -34,7 +40,6 @@ export async function loadDocContent(docPath) { try { // Try language-specific file first (e.g., about.de.adoc for German) const currentLang = i18n.currentLang() - let finalPath = docPath let response if (currentLang !== 'en') { @@ -45,9 +50,6 @@ export async function loadDocContent(docPath) { // If language-specific file not found, fallback to English if (!response.ok) { response = await fetch(`${import.meta.env.BASE_URL}${docPath}`) - finalPath = docPath - } else { - finalPath = langPath } } else { response = await fetch(`${import.meta.env.BASE_URL}${docPath}`) @@ -58,8 +60,9 @@ export async function loadDocContent(docPath) { } const adocContent = await response.text() - const htmlContent = asciidoctor.convert(adocContent, { - safe: 'safe', + const asciidocEngine = await getAsciidoctor() + const htmlContent = asciidocEngine.convert(adocContent, { + safe: 'secure', attributes: { 'source-highlighter': 'highlight.js', 'icons': 'font', @@ -69,7 +72,7 @@ export async function loadDocContent(docPath) { } }) - contentEl.innerHTML = htmlContent + contentEl.innerHTML = String(htmlContent) // Auto-expand collapsible sections contentEl.querySelectorAll('details').forEach(details => { diff --git a/website/src/components/doc-page.test.js b/website/src/components/doc-page.test.js new file mode 100644 index 0000000..74a603c --- /dev/null +++ b/website/src/components/doc-page.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { i18n } from '../i18n.js' +import { loadDocContent } from './doc-page.js' + +describe('doc-page', () => { + beforeEach(() => { + localStorage.clear() + i18n.init() + document.body.innerHTML = '
' + global.fetch = vi.fn() + }) + + afterEach(() => { + delete global.fetch + }) + + it('falls back to English when localized document is missing', async () => { + i18n.setLang('de') + + global.fetch + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ ok: true, text: async () => '= About\n\nlink:https://example.com[Example]' }) + + await loadDocContent('docs/about.adoc') + + expect(global.fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('docs/about.de.adoc')) + expect(global.fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('docs/about.adoc')) + + const link = document.querySelector('#doc-content a[href="https://example.com"]') + expect(link).toBeTruthy() + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toContain('noopener') + }) + + it('renders an error state when loading fails', async () => { + global.fetch.mockResolvedValue({ ok: false, status: 500 }) + + await loadDocContent('docs/about.adoc') + + expect(document.getElementById('doc-content').textContent).toContain('Failed to Load Documentation') + }) +}) diff --git a/website/src/components/footer.js b/website/src/components/footer.js index ab544a1..1acc3af 100644 --- a/website/src/components/footer.js +++ b/website/src/components/footer.js @@ -5,7 +5,7 @@ export function renderFooter(version) {