mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
Revert "docs: add TipTap migration testing strategy"
This reverts commit 64b6e60951dbefee64a54e688396099f370ee150.
This commit is contained in:
parent
317ec72c7e
commit
8e5c6a0907
376
TESTING_STRATEGY.md
Normal file
376
TESTING_STRATEGY.md
Normal file
@ -0,0 +1,376 @@
|
||||
# Teststrategie für TipTap Markdown-Migration
|
||||
|
||||
## Zusammenfassung der Analyse
|
||||
|
||||
Die TipTap-Migration umfasst folgende Kernkomponenten:
|
||||
|
||||
| Komponente | Beschreibung | Komplexität |
|
||||
|------------|--------------|-------------|
|
||||
| `lib/src/Components/TipTap/utils/preprocessMarkdown.ts` | 6-stufige Preprocessing-Pipeline | Hoch |
|
||||
| `lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx` | Statische HTML-Konvertierung | Mittel |
|
||||
| `lib/src/Components/TipTap/extensions/Hashtag.tsx` | Custom Extension mit Tokenizer | Mittel |
|
||||
| `lib/src/Components/TipTap/extensions/ItemMention.tsx` | Custom Extension mit Tokenizer | Mittel |
|
||||
| `lib/src/Components/TipTap/extensions/VideoEmbed.tsx` | Block-Element für Videos | Mittel |
|
||||
| `lib/src/Components/Input/RichTextEditor.tsx` | Haupt-Editor-Komponente | Hoch |
|
||||
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx` | Read-Only Editor | Mittel |
|
||||
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx` | Lightweight Static Renderer | Niedrig |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Teststrategie: Testing Pyramid
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ E2E Tests │ ← Wenige, kritische User Journeys
|
||||
│ (Cypress) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ Integration │ ← TipTap + Extensions zusammen
|
||||
│ Tests (Vitest) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
│ Unit Tests (Vitest) │ ← Utility-Funktionen isoliert
|
||||
│ preprocessMarkdown, simpleMarkdownToHtml │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Begründung der Strategie
|
||||
|
||||
1. **Unit Tests für Utility-Funktionen (Hauptfokus)**
|
||||
- `preprocessMarkdown.ts` und `simpleMarkdownToHtml.tsx` sind **pure Funktionen** ohne Abhängigkeiten
|
||||
- Extrem schnelle Ausführung, hohe Coverage möglich
|
||||
- Einfach zu warten und zu debuggen
|
||||
- Hier liegt die meiste **Geschäftslogik** der Markdown-Verarbeitung
|
||||
|
||||
2. **Integration Tests für TipTap Extensions**
|
||||
- Extensions benötigen einen Editor-Kontext
|
||||
- Testen der Markdown ↔ JSON ↔ HTML Roundtrips
|
||||
- Mäßiger Aufwand, gute Fehlererkennung
|
||||
|
||||
3. **E2E Tests nur für kritische User Journeys**
|
||||
- Hoher Wartungsaufwand
|
||||
- Langsame Ausführung
|
||||
- Für Smoke Tests und Regressionsschutz
|
||||
|
||||
---
|
||||
|
||||
## Detaillierte Testfälle
|
||||
|
||||
### 1. Unit Tests für `preprocessMarkdown.ts`
|
||||
|
||||
#### A) `convertNakedUrls`
|
||||
|
||||
| Testfall | Input | Expected Output |
|
||||
|----------|-------|-----------------|
|
||||
| **Happy Path** | `Check https://example.com out` | `Check [example.com](https://example.com) out` |
|
||||
| **www entfernen** | `https://www.example.com` | `[example.com](https://example.com)` |
|
||||
| **URL in Markdown-Link (Skip)** | `[link](https://example.com)` | Unverändert |
|
||||
| **URL in Autolink (Skip)** | `<https://example.com>` | Unverändert |
|
||||
| **Mehrere URLs** | `https://a.com and https://b.com` | Beide konvertiert |
|
||||
| **URL am Satzende mit Punkt** | `Visit https://example.com.` | Punkt nicht Teil der URL |
|
||||
| **URL mit Klammern** | `(https://example.com)` | Klammern korrekt behandelt |
|
||||
| **URL mit Query-Params** | `https://example.com?a=1&b=2` | Vollständig konvertiert |
|
||||
|
||||
#### B) `preprocessVideoLinks`
|
||||
|
||||
| Testfall | Input | Expected Output |
|
||||
|----------|-------|-----------------|
|
||||
| **YouTube Standard** | `<https://www.youtube.com/watch?v=abc123>` | `<video-embed provider="youtube" video-id="abc123">` |
|
||||
| **YouTube Short URL** | `<https://youtu.be/abc123>` | Korrekt konvertiert |
|
||||
| **YouTube Markdown Link** | `[Video](https://youtube.com/watch?v=abc123)` | Korrekt konvertiert |
|
||||
| **Rumble Embed** | `<https://rumble.com/embed/xyz789>` | `<video-embed provider="rumble"...>` |
|
||||
| **URL mit Extra-Params** | `<https://youtube.com/watch?v=abc&t=120>` | Nur Video-ID extrahiert |
|
||||
| **Kein Video-Link** | `<https://example.com>` | Unverändert |
|
||||
| **Gemischter Content** | `Text <https://youtu.be/x> more` | Nur Video konvertiert |
|
||||
|
||||
#### C) `preprocessHashtags`
|
||||
|
||||
| Testfall | Input | Expected Output |
|
||||
|----------|-------|-----------------|
|
||||
| **Einfacher Hashtag** | `Hello #world` | `Hello <span data-hashtag...>#world</span>` |
|
||||
| **Hashtag mit Umlauten** | `#München` | Korrekt erkannt |
|
||||
| **Hashtag mit Zahlen** | `#test123` | Korrekt erkannt |
|
||||
| **Hashtag in Link (Skip)** | `[#tag](#anchor)` | Unverändert |
|
||||
| **Hashtag nach Klammer (Skip)** | `(#section)` | Unverändert |
|
||||
| **Mehrere Hashtags** | `#one #two #three` | Alle konvertiert |
|
||||
| **Ungültiger Hashtag** | `#` | Unverändert (kein Text) |
|
||||
| **Hashtag mit Underscore** | `#my_tag` | Korrekt erkannt |
|
||||
|
||||
#### D) `preprocessItemMentions`
|
||||
|
||||
| Testfall | Input | Expected Output |
|
||||
|----------|-------|-----------------|
|
||||
| **Standard Format** | `[@Person](/item/uuid-123)` | `<span data-item-mention...>@Person</span>` |
|
||||
| **Mit Layer (Legacy)** | `[@Name](/item/layer/uuid)` | Korrekt konvertiert |
|
||||
| **Relativer Pfad** | `[@Name](item/uuid)` | Korrekt konvertiert |
|
||||
| **Mehrere Mentions** | `[@A](/item/1) und [@B](/item/2)` | Beide konvertiert |
|
||||
| **Kein Item-Link** | `[@Name](/other/path)` | Unverändert |
|
||||
| **UUID Case-Insensitive** | `[@Name](/item/ABC-def-123)` | Korrekt erkannt |
|
||||
| **Label mit Sonderzeichen** | `[@Max Müller](/item/uuid)` | Korrekt konvertiert |
|
||||
|
||||
#### E) `truncateMarkdown`
|
||||
|
||||
| Testfall | Input | Limit | Expected |
|
||||
|----------|-------|-------|----------|
|
||||
| **Unter Limit** | `Short text` | 100 | Unverändert |
|
||||
| **Über Limit (Plain)** | `A very long text...` | 10 | `A very lo...` |
|
||||
| **Hashtag nicht schneiden** | `Text #verylonghashtag more` | 15 | Vollständiger Hashtag oder davor abschneiden |
|
||||
| **Mention nicht schneiden** | `Hi [@Person](/item/x) bye` | 10 | Vollständige Mention oder davor |
|
||||
| **Link nicht schneiden** | `See [link](url) more` | 8 | Vollständiger Link oder davor |
|
||||
| **Newlines nicht zählen** | `Line1\n\nLine2` | 10 | Newlines ignoriert bei Zählung |
|
||||
| **Gemischter Content** | `#tag [@m](/item/1) text` | 20 | Tokens atomar |
|
||||
|
||||
#### F) `removeMarkdownSyntax`
|
||||
|
||||
| Testfall | Input | Expected |
|
||||
|----------|-------|----------|
|
||||
| **Bold** | `**bold**` | `bold` |
|
||||
| **Italic** | `*italic*` | `italic` |
|
||||
| **Headers** | `# Heading` | `Heading` |
|
||||
| **Links** | `[text](url)` | `text` |
|
||||
| **Item Mentions erhalten** | `[@Name](/item/x)` | Erhalten |
|
||||
| **Bilder entfernen** | `` | Leer |
|
||||
| **Code** | `` `code` `` | `code` |
|
||||
|
||||
---
|
||||
|
||||
### 2. Unit Tests für `simpleMarkdownToHtml.tsx`
|
||||
|
||||
| Testfall | Input | Expected HTML |
|
||||
|----------|-------|---------------|
|
||||
| **Bold** | `**bold**` | `<strong>bold</strong>` |
|
||||
| **Italic** | `*italic*` | `<em>italic</em>` |
|
||||
| **Link** | `[text](url)` | `<a href="url">text</a>` |
|
||||
| **External Link** | `[text](https://ext.com)` | `<a href="..." target="_blank"...>` |
|
||||
| **Header H1** | `# Title` | `<h1>Title</h1>` |
|
||||
| **Header H2-H6** | `## ... ######` | Entsprechende h-Tags |
|
||||
| **Inline Code** | `` `code` `` | `<code>code</code>` |
|
||||
| **Blockquote** | `> quote` | `<blockquote>quote</blockquote>` |
|
||||
| **Video Embed** | `<video-embed provider="youtube"...>` | `<iframe src="youtube-nocookie...">` |
|
||||
| **Hashtag mit Farbe** | Preprocessed Hashtag + Tag mit color | Style mit korrekter Farbe |
|
||||
| **Item Mention** | Preprocessed Mention + Item | Link mit korrekter Farbe |
|
||||
| **XSS Prevention** | `<script>alert('xss')</script>` | Escaped, kein Script-Tag |
|
||||
| **Newlines** | `Line1\n\nLine2` | `</p><p>` Trennung |
|
||||
|
||||
---
|
||||
|
||||
### 3. Integration Tests für TipTap Extensions
|
||||
|
||||
Diese Tests benötigen einen TipTap Editor-Kontext. Setup via `@tiptap/core`:
|
||||
|
||||
```typescript
|
||||
// Test-Setup Beispiel
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { Hashtag } from './Hashtag'
|
||||
```
|
||||
|
||||
#### A) Hashtag Extension
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Markdown → JSON** | `#tag` wird zu `{ type: 'hashtag', attrs: { label: 'tag' } }` |
|
||||
| **JSON → Markdown** | Hashtag-Node wird zu `#tag` serialisiert |
|
||||
| **HTML Parse** | `<span data-hashtag data-label="x">#x</span>` wird erkannt |
|
||||
| **HTML Render** | Node rendert korrekte HTML-Struktur |
|
||||
| **Tokenizer Start** | `/(?<!\[)#[a-zA-Z]/` matched korrekt |
|
||||
| **Click Handler (read-only)** | `onTagClick` wird aufgerufen |
|
||||
| **No Click (editable)** | Kein Click-Handler im Edit-Modus |
|
||||
|
||||
#### B) ItemMention Extension
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Markdown → JSON** | `[@Name](/item/uuid)` wird zu korrektem Node |
|
||||
| **JSON → Markdown** | Node wird korrekt serialisiert |
|
||||
| **Mit Layer** | Legacy-Format wird korrekt geparst |
|
||||
| **UUID Case-Insensitive** | Groß-/Kleinschreibung egal |
|
||||
| **Farbe aus Item** | `getItemColor` wird korrekt verwendet |
|
||||
|
||||
#### C) VideoEmbed Extension
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **YouTube Parse** | Autolink zu Video-Node |
|
||||
| **Rumble Parse** | Embed-URL zu Video-Node |
|
||||
| **Iframe Render** | Korrekter `youtube-nocookie.com` Embed |
|
||||
| **Paste Handler** | Video-URL wird beim Einfügen erkannt |
|
||||
|
||||
#### D) Roundtrip Tests
|
||||
|
||||
| Testfall | Flow |
|
||||
|----------|------|
|
||||
| **Markdown Roundtrip** | Markdown → Editor → `getMarkdown()` → identisch |
|
||||
| **Komplexer Content** | Text + #tag + @mention + Video → Roundtrip |
|
||||
| **Preserve Formatting** | Bold, Italic, Listen bleiben erhalten |
|
||||
|
||||
---
|
||||
|
||||
### 4. Component Tests (React Testing Library)
|
||||
|
||||
#### RichTextEditor
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Render mit Default** | Editor rendert mit initialem Content |
|
||||
| **onChange Callback** | `updateFormValue` erhält Markdown |
|
||||
| **Placeholder** | Placeholder wird angezeigt |
|
||||
| **Hashtag Suggestion** | `#` triggert Suggestion Popup |
|
||||
| **Item Mention Suggestion** | `@` triggert Suggestion Popup |
|
||||
| **Keyboard Navigation** | Arrow Keys in Suggestions |
|
||||
| **Suggestion Select** | Enter fügt Tag ein |
|
||||
|
||||
#### TextView
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Read-Only** | Editor ist nicht editierbar |
|
||||
| **Truncation** | Langer Text wird gekürzt + `...` |
|
||||
| **Hashtag Click** | `addFilterTag` wird aufgerufen |
|
||||
| **Internal Link** | React Router Navigation |
|
||||
| **External Link** | Neuer Tab |
|
||||
|
||||
#### TextViewStatic
|
||||
|
||||
| Testfall | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **HTML Render** | Markdown wird zu HTML |
|
||||
| **Hashtag Farbe** | Farbe aus Tags-Array |
|
||||
| **Item Mention Link** | Korrekter `/item/` Link |
|
||||
| **Video Embed** | Iframe wird gerendert |
|
||||
|
||||
---
|
||||
|
||||
### 5. E2E Tests (Cypress)
|
||||
|
||||
Nur **kritische User Journeys** - minimaler Scope für maximale Stabilität:
|
||||
|
||||
#### A) Editor Flow
|
||||
|
||||
```typescript
|
||||
describe('Rich Text Editor', () => {
|
||||
it('should create and save content with hashtags and mentions', () => {
|
||||
// 1. Neues Item erstellen
|
||||
// 2. Text eingeben mit #tag und @mention
|
||||
// 3. Speichern
|
||||
// 4. Popup öffnen und Rendering prüfen
|
||||
})
|
||||
|
||||
it('should handle video embeds', () => {
|
||||
// YouTube URL einfügen → Video-Embed sichtbar
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### B) Display Flow
|
||||
|
||||
```typescript
|
||||
describe('Text Display', () => {
|
||||
it('should render hashtags clickable in popup', () => {
|
||||
// Item mit Hashtag öffnen
|
||||
// Hashtag klicken
|
||||
// Filter wird aktiviert
|
||||
})
|
||||
|
||||
it('should navigate to mentioned items', () => {
|
||||
// Item mit @mention öffnen
|
||||
// Mention klicken
|
||||
// Navigation zum verlinkten Item
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priorisierung der Implementierung
|
||||
|
||||
### Phase 1: Unit Tests (Höchste Priorität)
|
||||
|
||||
| Datei | Geschätzte Tests | Grund |
|
||||
|-------|------------------|-------|
|
||||
| `preprocessMarkdown.spec.ts` | ~40 Tests | Pure Functions, schnell, hohe Coverage |
|
||||
| `simpleMarkdownToHtml.spec.ts` | ~25 Tests | Pure Function, XSS-kritisch |
|
||||
|
||||
### Phase 2: Integration Tests
|
||||
|
||||
| Datei | Geschätzte Tests | Grund |
|
||||
|-------|------------------|-------|
|
||||
| `Hashtag.spec.ts` | ~15 Tests | Custom Extension mit Tokenizer |
|
||||
| `ItemMention.spec.ts` | ~15 Tests | Custom Extension mit Tokenizer |
|
||||
| `VideoEmbed.spec.ts` | ~10 Tests | Block-Element |
|
||||
|
||||
### Phase 3: Component Tests
|
||||
|
||||
| Datei | Geschätzte Tests | Grund |
|
||||
|-------|------------------|-------|
|
||||
| `RichTextEditor.spec.tsx` | ~15 Tests | Haupt-Editor |
|
||||
| `TextView.spec.tsx` | ~10 Tests | Read-Only Variante |
|
||||
| `TextViewStatic.spec.tsx` | ~8 Tests | Lightweight Renderer |
|
||||
|
||||
### Phase 4: E2E Tests (Niedrigste Priorität)
|
||||
|
||||
| Datei | Geschätzte Tests | Grund |
|
||||
|-------|------------------|-------|
|
||||
| `editor-flow.cy.ts` | 3-5 Tests | Kritische User Journey |
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases und Error Handling
|
||||
|
||||
### Besonders wichtige Grenzfälle
|
||||
|
||||
1. **Leerer Input** - Alle Funktionen sollten mit `''`, `null`, `undefined` umgehen
|
||||
2. **Sehr langer Text** - Performance bei >10.000 Zeichen
|
||||
3. **Verschachtelte Syntax** - `**#bold-hashtag**`, `[[@mention](/item/x)](url)`
|
||||
4. **Unicode** - Emojis, RTL-Text, Sonderzeichen
|
||||
5. **Malformed Markdown** - Ungeschlossene Tags: `**bold`, `[link(`
|
||||
6. **XSS Vectors** - `<script>`, Event-Handler in Links
|
||||
7. **Concurrent Tokens** - `#tag1#tag2` (ohne Leerzeichen)
|
||||
8. **URLs in Code-Blöcken** - Sollten nicht konvertiert werden
|
||||
|
||||
---
|
||||
|
||||
## Test-Setup Empfehlungen
|
||||
|
||||
### Vitest Setup erweitern (`lib/setupTest.ts`)
|
||||
|
||||
```typescript
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// TipTap DOM-Mocks für Vitest (basierend auf Community-Empfehlungen)
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
|
||||
toJSON: vi.fn(),
|
||||
})
|
||||
Range.prototype.getClientRects = () => ({
|
||||
item: () => null, length: 0, [Symbol.iterator]: vi.fn(),
|
||||
})
|
||||
Document.prototype.elementFromPoint = vi.fn()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Die empfohlene Strategie fokussiert auf **Unit Tests für die Markdown-Utility-Funktionen**, da hier:
|
||||
- Die meiste Geschäftslogik liegt
|
||||
- Pure Functions einfach testbar sind
|
||||
- Hohe Coverage mit geringem Aufwand erreichbar ist
|
||||
|
||||
E2E Tests sollten auf ein Minimum beschränkt bleiben und nur kritische User Journeys abdecken. Dies folgt der Testing Pyramid Best Practice und sorgt für:
|
||||
- **Schnelles Feedback** (Unit Tests <1s)
|
||||
- **Hohe Wartbarkeit** (keine flaky UI-Tests)
|
||||
- **Gute Fehlerlokalisierung** (isolierte Tests)
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [TipTap Testing Discussion #4008](https://github.com/ueberdosis/tiptap/discussions/4008)
|
||||
- [TipTap Jest Issue #5108](https://github.com/ueberdosis/tiptap/issues/5108)
|
||||
- [Testing TipTap CodeSandbox](https://codesandbox.io/s/testing-tiptap-p0oomz)
|
||||
- [TipTap Contributing Guide](https://tiptap.dev/docs/resources/contributing)
|
||||
@ -1,752 +0,0 @@
|
||||
# TipTap Migration Testing Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the comprehensive testing strategy for the TipTap Markdown migration. The strategy uses a combination of **Vitest** for pure function unit tests and **Cypress Component Testing** for TipTap-dependent components, leveraging the project's existing test infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Component Overview
|
||||
|
||||
The TipTap migration includes the following core components:
|
||||
|
||||
| Component | Description | Test Tool |
|
||||
|-----------|-------------|-----------|
|
||||
| `lib/src/Components/TipTap/utils/preprocessMarkdown.ts` | 6-stage preprocessing pipeline | Vitest |
|
||||
| `lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx` | Static HTML conversion | Vitest |
|
||||
| `lib/src/Components/TipTap/extensions/Hashtag.tsx` | Custom extension with tokenizer | Cypress Component |
|
||||
| `lib/src/Components/TipTap/extensions/ItemMention.tsx` | Custom extension with tokenizer | Cypress Component |
|
||||
| `lib/src/Components/TipTap/extensions/VideoEmbed.tsx` | Block element for videos | Cypress Component |
|
||||
| `lib/src/Components/Input/RichTextEditor.tsx` | Main editor component | Cypress Component |
|
||||
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx` | Read-only editor | Cypress Component |
|
||||
| `lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx` | Lightweight static renderer | Vitest |
|
||||
| `lib/src/Utils/ReplaceURLs.ts` | URL/email processing utilities | Vitest |
|
||||
|
||||
---
|
||||
|
||||
## Testing Pyramid Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ E2E Tests │ 3-5 critical user journeys
|
||||
│ (Cypress E2E) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ Cypress Component Tests │ TipTap extensions + editors
|
||||
│ Real browser, no mocking │ Contract tests
|
||||
└───────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────────────────┴──────────────────────────┐
|
||||
│ Vitest Unit Tests │
|
||||
│ preprocessMarkdown, simpleMarkdownToHtml │
|
||||
│ Pure functions, security tests │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Vitest Unit Tests for Pure Functions (Primary Focus)**
|
||||
- `preprocessMarkdown.ts` and `simpleMarkdownToHtml.tsx` are **pure functions** without DOM dependencies
|
||||
- Extremely fast execution, high coverage achievable
|
||||
- Easy to maintain and debug
|
||||
- Contains most of the **business logic** for markdown processing
|
||||
- Includes **security/XSS tests** for HTML output
|
||||
|
||||
2. **Cypress Component Tests for TipTap Extensions**
|
||||
- TipTap requires a **real browser environment** (jsdom mocking is fragile and incomplete)
|
||||
- The project already has Cypress Component Testing configured (`lib/cypress.config.ts`)
|
||||
- Real browser provides native support for `Range`, `Selection`, `ResizeObserver`, etc.
|
||||
- Test Markdown ↔ JSON ↔ HTML roundtrips with actual TipTap editor
|
||||
- **Contract tests** verify preprocessing output is valid TipTap input
|
||||
|
||||
3. **E2E Tests Only for Critical User Journeys**
|
||||
- Uses existing Cypress E2E setup (`cypress/`)
|
||||
- Leverages existing custom commands (`cy.clickMarker()`, `cy.waitForPopup()`)
|
||||
- For smoke tests and regression protection
|
||||
|
||||
---
|
||||
|
||||
## Component Usage Context
|
||||
|
||||
Understanding where each component is used guides test priority:
|
||||
|
||||
| Context | Component | Rendering | Priority |
|
||||
|---------|-----------|-----------|----------|
|
||||
| **Map Popup** | `TextViewStatic` | `simpleMarkdownToHtml` (static HTML) | P0 - most visible |
|
||||
| **Item Card** | `TextViewStatic` | `simpleMarkdownToHtml` (static HTML) | P0 - list views |
|
||||
| **Item Profile** | `TextView` | TipTap editor (read-only) | P1 - detail view |
|
||||
| **Item Edit Form** | `RichTextEditor` | TipTap editor (editable) | P0 - data integrity |
|
||||
|
||||
---
|
||||
|
||||
## Test Setup
|
||||
|
||||
### Vitest Configuration (`lib/setupTest.ts`)
|
||||
|
||||
For **pure function unit tests**, no TipTap-specific mocks are needed:
|
||||
|
||||
```typescript
|
||||
import '@testing-library/jest-dom'
|
||||
```
|
||||
|
||||
> **Note:** TipTap editor tests use Cypress Component Testing instead of Vitest to avoid fragile jsdom mocks. This provides a real browser environment where `Range`, `Selection`, `ResizeObserver`, and other DOM APIs work natively.
|
||||
|
||||
### Cypress Component Testing (`lib/cypress.config.ts`)
|
||||
|
||||
Already configured in the project:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
component: {
|
||||
devServer: {
|
||||
framework: 'react',
|
||||
bundler: 'vite',
|
||||
},
|
||||
specPattern: ['**/**/*.cy.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Vitest unit tests
|
||||
cd lib && npm run test:unit
|
||||
|
||||
# Cypress component tests (interactive)
|
||||
cd lib && npx cypress open --component
|
||||
|
||||
# Cypress component tests (headless)
|
||||
cd lib && npx cypress run --component
|
||||
|
||||
# Cypress E2E tests
|
||||
cd cypress && npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Test Cases
|
||||
|
||||
### 1. Unit Tests: `preprocessMarkdown.ts`
|
||||
|
||||
#### A) `convertNakedUrls` (internal function)
|
||||
|
||||
| Category | Test Case | Input | Expected Output |
|
||||
|----------|-----------|-------|-----------------|
|
||||
| **Happy Path** | Basic URL | `"Check https://example.com out"` | `"Check [example.com](https://example.com) out"` |
|
||||
| **Happy Path** | Remove www | `"https://www.example.com"` | `"[example.com](https://example.com)"` |
|
||||
| **Happy Path** | Multiple URLs | `"https://a.com and https://b.com"` | Both converted |
|
||||
| **Happy Path** | URL with query params | `"https://example.com?a=1&b=2"` | Full URL preserved in link |
|
||||
| **Skip** | URL in markdown link | `"[link](https://example.com)"` | Unchanged |
|
||||
| **Skip** | URL in autolink | `"<https://example.com>"` | Unchanged |
|
||||
| **Edge** | URL at sentence end | `"Visit https://example.com."` | Dot NOT part of URL |
|
||||
| **Edge** | URL in parentheses | `"(https://example.com)"` | Parentheses handled correctly |
|
||||
| **Edge** | URL at line start | `"https://example.com is good"` | Converted correctly |
|
||||
|
||||
#### B) `preprocessVideoLinks`
|
||||
|
||||
| Category | Test Case | Input | Expected Output |
|
||||
|----------|-----------|-------|-----------------|
|
||||
| **Happy Path** | YouTube standard | `"<https://www.youtube.com/watch?v=abc123def45>"` | `<video-embed provider="youtube" video-id="abc123def45">` |
|
||||
| **Happy Path** | YouTube short | `"<https://youtu.be/abc123def45>"` | Same as above |
|
||||
| **Happy Path** | YouTube markdown link | `"[Video](https://youtube.com/watch?v=abc123def45)"` | Converted |
|
||||
| **Happy Path** | Rumble embed | `"<https://rumble.com/embed/v1abc>"` | `<video-embed provider="rumble"...>` |
|
||||
| **Edge** | Extra params | `"<https://youtube.com/watch?v=abc&t=120>"` | Only video-id extracted |
|
||||
| **Edge** | Non-video link | `"<https://example.com>"` | Unchanged |
|
||||
| **Edge** | Mixed content | `"Text <https://youtu.be/x> more"` | Only video converted |
|
||||
| **Error** | Invalid video ID | `"[V](https://youtube.com/watch?v=)"` | Unchanged (no match) |
|
||||
|
||||
#### C) `preprocessHashtags`
|
||||
|
||||
| Category | Test Case | Input | Expected Output |
|
||||
|----------|-----------|-------|-----------------|
|
||||
| **Happy Path** | Simple hashtag | `"Hello #world"` | `"Hello <span data-hashtag data-label=\"world\">#world</span>"` |
|
||||
| **Happy Path** | Multiple hashtags | `"#one #two #three"` | All converted |
|
||||
| **Happy Path** | With numbers | `"#test123"` | Converted |
|
||||
| **Happy Path** | With underscore | `"#my_tag"` | Converted |
|
||||
| **Happy Path** | Unicode (umlauts) | `"#München"` | `data-label="München"` |
|
||||
| **Happy Path** | Unicode (accents) | `"#café"` | Converted |
|
||||
| **Skip** | Hashtag in link text | `"[#tag](#anchor)"` | Unchanged |
|
||||
| **Skip** | Hashtag in link URL | `"[section](#section)"` | Unchanged |
|
||||
| **Edge** | Concurrent hashtags | `"#tag1#tag2"` | Only `#tag1` converted (no space) |
|
||||
| **Edge** | Hashtag only `#` | `"Just #"` | Unchanged |
|
||||
| **Edge** | Hashtag with hyphen | `"#my-tag"` | Converted |
|
||||
|
||||
#### D) `preprocessItemMentions`
|
||||
|
||||
| Category | Test Case | Input | Expected Output |
|
||||
|----------|-----------|-------|-----------------|
|
||||
| **Happy Path** | Standard format | `"[@Alice](/item/abc-123)"` | `<span data-item-mention data-label="Alice" data-id="abc-123">@Alice</span>` |
|
||||
| **Happy Path** | With layer (legacy) | `"[@Bob](/item/people/def-456)"` | `data-id="def-456"` extracted |
|
||||
| **Happy Path** | Relative path | `"[@Name](item/uuid)"` | Converted |
|
||||
| **Happy Path** | Multiple mentions | `"[@A](/item/1) and [@B](/item/2)"` | Both converted |
|
||||
| **Happy Path** | UUID case-insensitive | `"[@Name](/item/ABC-DEF-123)"` | Converted |
|
||||
| **Happy Path** | Label with spaces | `"[@Max Müller](/item/uuid)"` | Converted |
|
||||
| **Skip** | Non-item link | `"[@Name](/other/path)"` | Unchanged |
|
||||
| **Skip** | Regular link | `"[Name](/item/123)"` | Unchanged (no @) |
|
||||
|
||||
#### E) `truncateMarkdown`
|
||||
|
||||
| Category | Test Case | Input | Limit | Expected |
|
||||
|----------|-----------|-------|-------|----------|
|
||||
| **Happy Path** | Under limit | `"Short text"` | 100 | Unchanged |
|
||||
| **Happy Path** | At limit | `"A".repeat(150)` | 100 | `"A".repeat(100) + "..."` |
|
||||
| **Atomic** | Preserve hashtag | `"A".repeat(95) + " #tag"` | 100 | Complete `#tag` or cut before |
|
||||
| **Atomic** | Preserve mention | `"A".repeat(90) + " [@X](/item/1)"` | 100 | Complete mention or cut before |
|
||||
| **Atomic** | Preserve link | `"See [link](url) more"` | 8 | Complete link or cut before |
|
||||
| **Edge** | Newlines don't count | `"Line1\n\nLine2"` | 10 | Newlines not counted |
|
||||
| **Edge** | Empty text | `""` | 100 | `""` |
|
||||
| **Edge** | Limit 0 | `"Text"` | 0 | `"..."` |
|
||||
| **Error** | Negative limit | `"Text"` | -1 | No throw |
|
||||
|
||||
#### F) `removeMarkdownSyntax`
|
||||
|
||||
| Category | Test Case | Input | Expected |
|
||||
|----------|-----------|-------|----------|
|
||||
| **Happy Path** | Bold | `"**bold**"` | `"bold"` |
|
||||
| **Happy Path** | Italic | `"*italic*"` | `"italic"` |
|
||||
| **Happy Path** | Headers | `"# Heading"` | `"Heading"` |
|
||||
| **Happy Path** | Links | `"[text](url)"` | `"text"` |
|
||||
| **Happy Path** | Images | `""` | `""` |
|
||||
| **Happy Path** | Inline code | `` "`code`" `` | `"code"` |
|
||||
| **Preserve** | Item mentions | `"[@Name](/item/x)"` | Preserved (contains @) |
|
||||
| **Preserve** | Hashtags | `"#tag"` | Preserved |
|
||||
|
||||
#### G) Full Pipeline `preprocessMarkdown`
|
||||
|
||||
| Category | Test Case | Input | Expected Behavior |
|
||||
|----------|-----------|-------|-------------------|
|
||||
| **Happy Path** | Complete content | `"Check https://x.com #tag [@A](/item/1)"` | All transformations applied |
|
||||
| **Edge** | Empty string | `""` | Returns `""` |
|
||||
| **Edge** | Null input | `null` | Returns `""` (no throw) |
|
||||
| **Edge** | Undefined input | `undefined` | Returns `""` (no throw) |
|
||||
| **Edge** | Only whitespace | `" "` | Whitespace preserved |
|
||||
| **Edge** | Very long text | `"A".repeat(10000)` | Completes without timeout |
|
||||
| **Error** | Malformed markdown | `"[unclosed link"` | No throw |
|
||||
| **Error** | Malformed URL | `"http:/broken"` | No throw |
|
||||
|
||||
---
|
||||
|
||||
### 2. Unit Tests: `simpleMarkdownToHtml.tsx`
|
||||
|
||||
| Category | Test Case | Input | Expected HTML |
|
||||
|----------|-----------|-------|---------------|
|
||||
| **Happy Path** | Bold | `"**bold**"` | `<strong>bold</strong>` |
|
||||
| **Happy Path** | Italic | `"*italic*"` | `<em>italic</em>` |
|
||||
| **Happy Path** | Inline code | `` "`code`" `` | `<code>code</code>` |
|
||||
| **Happy Path** | External link | `"[text](https://x.com)"` | `<a href="..." target="_blank" rel="noopener noreferrer">` |
|
||||
| **Happy Path** | Internal link | `"[profile](/profile)"` | `<a href="/profile">` (no target) |
|
||||
| **Happy Path** | Headers H1-H6 | `"# Title"` ... `"###### Sub"` | Corresponding h1-h6 tags |
|
||||
| **Happy Path** | Blockquote | `"> quote"` | `<blockquote>quote</blockquote>` |
|
||||
| **Happy Path** | Paragraph break | `"Para1\n\nPara2"` | `</p><p>` |
|
||||
| **Happy Path** | Line break | `"Line1\nLine2"` | `<br>` |
|
||||
| **Happy Path** | Video embed | `<video-embed provider="youtube" video-id="abc">` | `<iframe src="...youtube-nocookie.com/embed/abc"` |
|
||||
| **Happy Path** | Hashtag with color | Preprocessed hashtag + tag with `color: #ff0000` | `style="color: #ff0000"` |
|
||||
| **Happy Path** | Item mention | Preprocessed mention + item in list | `<a href="/item/..." class="item-mention"` |
|
||||
| **Edge** | Empty string | `""` | `""` |
|
||||
| **Edge** | Unknown tag | Hashtag for unknown tag | `color: inherit` |
|
||||
| **Edge** | Unknown item | Mention for unknown item | Fallback color |
|
||||
| **Edge** | Consecutive newlines | `"\n\n\n\n"` | No excessive empty elements |
|
||||
| **Security** | XSS script tag | `"<script>alert(1)</script>"` | `<script>` escaped |
|
||||
| **Security** | XSS event handler | `"<img onerror=alert(1)>"` | Escaped |
|
||||
| **Security** | Already escaped | `"&"` | Preserved correctly |
|
||||
|
||||
---
|
||||
|
||||
### 3. Unit Tests: Dependency Functions (`ReplaceURLs.ts`)
|
||||
|
||||
The preprocessing pipeline depends on `fixUrls` and `mailRegex` from `lib/src/Utils/ReplaceURLs.ts`. These must be tested:
|
||||
|
||||
#### A) `fixUrls`
|
||||
|
||||
| Category | Test Case | Input | Expected Output |
|
||||
|----------|-----------|-------|-----------------|
|
||||
| **Happy Path** | Add https to naked domain | `"Visit example.com today"` | `"Visit https://example.com today"` |
|
||||
| **Happy Path** | Preserve existing https | `"https://example.com"` | Unchanged |
|
||||
| **Happy Path** | Preserve existing http | `"http://example.com"` | Unchanged |
|
||||
| **Happy Path** | Multiple domains | `"a.com and b.org"` | Both get https:// |
|
||||
| **Edge** | Domain with path | `"example.com/page"` | `"https://example.com/page"` |
|
||||
| **Edge** | Domain with subdomain | `"sub.example.com"` | `"https://sub.example.com"` |
|
||||
| **Skip** | Inside markdown link | `"[link](example.com)"` | Behavior depends on implementation |
|
||||
|
||||
#### B) `mailRegex`
|
||||
|
||||
| Category | Test Case | Input | Should Match |
|
||||
|----------|-----------|-------|--------------|
|
||||
| **Happy Path** | Simple email | `"test@example.com"` | ✓ |
|
||||
| **Happy Path** | With subdomain | `"user@mail.example.com"` | ✓ |
|
||||
| **Happy Path** | With plus | `"user+tag@example.com"` | ✓ |
|
||||
| **Happy Path** | With dots | `"first.last@example.com"` | ✓ |
|
||||
| **Happy Path** | Country TLD | `"user@example.co.uk"` | ✓ |
|
||||
| **Edge** | Invalid - no @ | `"not-an-email"` | ✗ |
|
||||
| **Edge** | Invalid - no domain | `"user@"` | ✗ |
|
||||
| **Edge** | Invalid - no local | `"@example.com"` | ✗ |
|
||||
|
||||
---
|
||||
|
||||
### 4. Unit Tests: XSS Security (`xss.spec.ts`)
|
||||
|
||||
**Critical:** The `simpleMarkdownToHtml` function uses a tag restoration pattern that could be vulnerable to XSS. A dedicated security test suite is required.
|
||||
|
||||
#### XSS Attack Vectors
|
||||
|
||||
```typescript
|
||||
const XSS_VECTORS = [
|
||||
// Basic XSS
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<svg onload=alert(1)>',
|
||||
'<body onload=alert(1)>',
|
||||
|
||||
// URL-based XSS
|
||||
'[click](javascript:alert(1))',
|
||||
'[click](data:text/html,<script>alert(1)</script>)',
|
||||
'[click](vbscript:alert(1))',
|
||||
|
||||
// Tag restoration bypass attempts
|
||||
'<span data-hashtag onclick=alert(1)',
|
||||
'<video-embed onload=alert(1)',
|
||||
'<span data-hashtag data-label="x" onclick="alert(1)">#x</span>',
|
||||
|
||||
// Attribute injection
|
||||
'#tag" onclick="alert(1)',
|
||||
'[@Name" onclick="alert(1)](/item/123)',
|
||||
|
||||
// Unicode/encoding escapes
|
||||
'\\u003cscript\\u003ealert(1)\\u003c/script\\u003e',
|
||||
'%3Cscript%3Ealert(1)%3C/script%3E',
|
||||
]
|
||||
```
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Script Tags** | `<script>alert(1)</script>` | No `<script` in output |
|
||||
| **Event Handlers** | `onerror=`, `onload=`, `onclick=` | No `on*=` in output |
|
||||
| **JavaScript URLs** | `javascript:alert(1)` | No `javascript:` in href |
|
||||
| **Data URLs** | `data:text/html,...` | No `data:` in href |
|
||||
| **Tag Restoration Bypass** | `<span data-hashtag onclick=` | No `onclick` in output |
|
||||
| **Attribute Injection** | `#tag" onclick="alert(1)` | Quotes properly escaped |
|
||||
|
||||
---
|
||||
|
||||
### 5. Cypress Component Tests: TipTap Extensions
|
||||
|
||||
> **Note:** TipTap extension tests use **Cypress Component Testing** instead of Vitest to leverage a real browser environment. This eliminates the need for fragile jsdom mocks.
|
||||
|
||||
#### Test Wrapper Pattern
|
||||
|
||||
```typescript
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { StarterKit } from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { Hashtag } from './Hashtag'
|
||||
|
||||
interface TestEditorProps {
|
||||
content: string
|
||||
tags?: { name: string; color: string }[]
|
||||
onTagClick?: (tag: any) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
function TestEditor({ content, tags = [], onTagClick, editable = false }: TestEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Hashtag.configure({ tags, onTagClick }),
|
||||
],
|
||||
content,
|
||||
contentType: 'markdown',
|
||||
editable,
|
||||
})
|
||||
return <EditorContent editor={editor} />
|
||||
}
|
||||
```
|
||||
|
||||
#### A) Hashtag Extension
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Parse** | Markdown → JSON | `"#tag"` → `{ type: 'hashtag', attrs: { label: 'tag' } }` |
|
||||
| **Serialize** | JSON → Markdown | Hashtag node → `"#tag"` |
|
||||
| **HTML Parse** | `<span data-hashtag data-label="x">#x</span>` | Recognized as hashtag node |
|
||||
| **HTML Render** | Node → HTML | Contains `data-hashtag`, `class="hashtag"` |
|
||||
| **Tokenizer** | Start hint | `/(?<!\[)#[a-zA-Z]/` matches correctly |
|
||||
| **Behavior** | Click in view mode | `onTagClick` callback fired |
|
||||
| **Behavior** | Click in edit mode | No callback fired |
|
||||
| **Styling** | Known tag | Applies tag color |
|
||||
| **Styling** | Unknown tag | Uses `inherit` |
|
||||
|
||||
#### B) ItemMention Extension
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Parse** | Markdown → JSON | `"[@Name](/item/uuid)"` → correct node |
|
||||
| **Parse** | With layer path | `"[@Name](/item/layer/uuid)"` → extracts uuid |
|
||||
| **Parse** | Case-insensitive UUID | `"[@X](/item/ABC-DEF)"` → works |
|
||||
| **Serialize** | JSON → Markdown | Node → `"[@Name](/item/uuid)"` |
|
||||
| **Styling** | Known item | Uses `getItemColor()` |
|
||||
| **Styling** | Unknown item | Uses `var(--color-primary)` |
|
||||
| **Behavior** | Click in view mode | Navigates to `/item/{id}` |
|
||||
| **Behavior** | Click in edit mode | No navigation |
|
||||
|
||||
#### C) VideoEmbed Extension
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Parse** | YouTube autolink | `"<https://youtube.com/watch?v=abc>"` → videoEmbed node |
|
||||
| **Parse** | YouTube short | `"<https://youtu.be/abc>"` → videoEmbed node |
|
||||
| **Parse** | Rumble | `"<https://rumble.com/embed/xyz>"` → videoEmbed node |
|
||||
| **Serialize** | YouTube node → Markdown | `"<https://www.youtube.com/watch?v=abc>"` |
|
||||
| **Render** | YouTube | iframe with `youtube-nocookie.com` |
|
||||
| **Render** | Rumble | iframe with `rumble.com/embed/` |
|
||||
| **Paste** | Paste YouTube URL | Video embed node inserted |
|
||||
| **Paste** | Paste non-video URL | Normal text paste |
|
||||
|
||||
#### D) Roundtrip Tests (Critical)
|
||||
|
||||
| Test Case | Flow |
|
||||
|-----------|------|
|
||||
| **Simple text** | Markdown → Editor → `getMarkdown()` → identical |
|
||||
| **With hashtag** | `"Hello #world"` → roundtrip → identical |
|
||||
| **With mention** | `"Thanks [@Alice](/item/123)"` → roundtrip → identical |
|
||||
| **With video** | `"<https://youtu.be/abc>"` → roundtrip → identical |
|
||||
| **Complex** | Text + hashtag + mention + video → roundtrip → identical |
|
||||
| **Formatting** | Bold, italic, lists → roundtrip → preserved |
|
||||
|
||||
#### E) Contract Tests (Critical)
|
||||
|
||||
Contract tests verify that the **output of `preprocessMarkdown()` is valid input for TipTap**. These catch integration failures that unit tests miss.
|
||||
|
||||
```typescript
|
||||
// lib/src/Components/TipTap/__tests__/contracts.cy.tsx
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { StarterKit } from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { Hashtag, ItemMention, VideoEmbed } from '../extensions'
|
||||
import { preprocessMarkdown } from '../utils/preprocessMarkdown'
|
||||
|
||||
function ContractTestEditor({ rawContent }: { rawContent: string }) {
|
||||
const preprocessed = preprocessMarkdown(rawContent)
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Markdown, Hashtag.configure({ tags: [] }), ItemMention, VideoEmbed],
|
||||
content: preprocessed,
|
||||
})
|
||||
return <EditorContent editor={editor} data-testid="editor" />
|
||||
}
|
||||
|
||||
describe('Preprocessing → TipTap Contract', () => {
|
||||
it('preprocessed hashtag renders correctly', () => {
|
||||
mount(<ContractTestEditor rawContent="#nature" />)
|
||||
cy.get('.hashtag').should('contain', '#nature')
|
||||
})
|
||||
|
||||
it('preprocessed mention renders correctly', () => {
|
||||
mount(<ContractTestEditor rawContent="[@Alice](/item/123-abc)" />)
|
||||
cy.get('.item-mention').should('contain', '@Alice')
|
||||
})
|
||||
|
||||
it('preprocessed video renders as iframe', () => {
|
||||
mount(<ContractTestEditor rawContent="<https://youtu.be/abc123>" />)
|
||||
cy.get('iframe').should('have.attr', 'src').and('include', 'youtube-nocookie.com')
|
||||
})
|
||||
|
||||
it('complex content renders all elements', () => {
|
||||
mount(<ContractTestEditor rawContent="Hello #world with [@Bob](/item/456) and <https://youtu.be/xyz>" />)
|
||||
cy.get('.hashtag').should('exist')
|
||||
cy.get('.item-mention').should('exist')
|
||||
cy.get('iframe').should('exist')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
| Test Case | Verification |
|
||||
|-----------|--------------|
|
||||
| **Hashtag contract** | Preprocessed `#tag` → TipTap renders `.hashtag` element |
|
||||
| **Mention contract** | Preprocessed `[@Name](/item/id)` → TipTap renders `.item-mention` |
|
||||
| **Video contract** | Preprocessed `<https://youtu.be/x>` → TipTap renders `iframe` |
|
||||
| **Complex contract** | All three together render correctly |
|
||||
| **Empty content** | Empty string doesn't crash |
|
||||
| **Malformed content** | Unclosed markdown doesn't crash |
|
||||
|
||||
---
|
||||
|
||||
### 6. Cypress Component Tests: Editor Components
|
||||
|
||||
#### RichTextEditor (`RichTextEditor.cy.tsx`)
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Render** | With default value | Editor displays content |
|
||||
| **Callback** | Type text | `updateFormValue` receives markdown |
|
||||
| **Placeholder** | Empty editor | Placeholder visible |
|
||||
| **Suggestion** | Type `#` | Hashtag suggestion popup appears |
|
||||
| **Suggestion** | Type `@` | Item mention suggestion popup appears |
|
||||
| **Keyboard** | Arrow keys in suggestions | Navigation works |
|
||||
| **Keyboard** | Enter in suggestions | Item selected |
|
||||
| **Keyboard** | Escape | Popup closes |
|
||||
| **New tag** | Select "Create #newTag" | `addTag` called, node inserted |
|
||||
|
||||
#### TextView (`TextView.cy.tsx`)
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Render** | With text | TipTap editor in read-only mode |
|
||||
| **Empty** | `text=""` | Returns `null` |
|
||||
| **Null** | `text=null` | Returns `null` |
|
||||
| **Undefined** | `text=undefined` | Shows login prompt |
|
||||
| **Truncation** | `truncate=true` | Text ends with `...` |
|
||||
| **Hashtag** | Click hashtag | `addFilterTag` called |
|
||||
| **Link** | Click internal link | React Router navigation |
|
||||
| **Link** | Click external link | Opens new tab |
|
||||
|
||||
---
|
||||
|
||||
### 7. Vitest Component Tests: Static Renderer
|
||||
|
||||
#### TextViewStatic (`TextViewStatic.spec.ts`)
|
||||
|
||||
> **Note:** `TextViewStatic` does not use TipTap - it renders HTML directly via `simpleMarkdownToHtml`. Therefore it uses **Vitest** instead of Cypress Component Testing.
|
||||
|
||||
| Category | Test Case | Verification |
|
||||
|----------|-----------|--------------|
|
||||
| **Render** | With text | HTML rendered via `dangerouslySetInnerHTML` |
|
||||
| **Empty** | `text=""` | Returns `null` |
|
||||
| **Undefined** | `text=undefined` | Shows login prompt |
|
||||
| **Truncation** | `truncate=true` | Truncated to ~100 chars |
|
||||
| **Hashtag** | Click hashtag | `addFilterTag` called |
|
||||
| **Hashtag** | Color applied | Tag color from tags array |
|
||||
| **Mention** | Rendered as link | `<a href="/item/...">` |
|
||||
| **Video** | Rendered as iframe | YouTube nocookie embed |
|
||||
| **Security** | XSS vectors | All escaped (see XSS test suite) |
|
||||
|
||||
---
|
||||
|
||||
### 8. E2E Tests (Minimal - Critical Journeys Only)
|
||||
|
||||
```typescript
|
||||
// cypress/e2e/tiptap/rich-text.cy.ts
|
||||
|
||||
describe('Rich Text Editor - Critical Flows', () => {
|
||||
beforeEach(() => {
|
||||
cy.login()
|
||||
cy.visit('/')
|
||||
cy.waitForMapReady()
|
||||
})
|
||||
|
||||
it('creates item with hashtags and mentions, verifies rendering', () => {
|
||||
// 1. Create new item
|
||||
cy.get('[data-cy="create-item-button"]').click()
|
||||
|
||||
// 2. Enter rich content
|
||||
cy.get('.ProseMirror').type('Project about #nature with [@Alice]')
|
||||
cy.get('[data-cy="suggestion-list"]').contains('Alice').click()
|
||||
|
||||
// 3. Save
|
||||
cy.get('[data-cy="save-button"]').click()
|
||||
cy.wait('@saveItem')
|
||||
|
||||
// 4. Verify popup rendering
|
||||
cy.get('[data-cy="item-popup"]').should('be.visible')
|
||||
cy.get('.hashtag').should('contain', '#nature')
|
||||
cy.get('.item-mention').should('contain', '@Alice')
|
||||
})
|
||||
|
||||
it('embeds video from pasted URL', () => {
|
||||
cy.get('[data-cy="create-item-button"]').click()
|
||||
cy.get('.ProseMirror').type('Check this: ')
|
||||
|
||||
// Paste video URL
|
||||
cy.get('.ProseMirror').invoke('val', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
.trigger('paste')
|
||||
|
||||
cy.get('.video-embed-wrapper iframe').should('be.visible')
|
||||
})
|
||||
|
||||
it('filters by hashtag when clicked in popup', () => {
|
||||
// Navigate to item with hashtag
|
||||
cy.clickMarker()
|
||||
cy.waitForPopup()
|
||||
|
||||
// Click hashtag
|
||||
cy.get('.hashtag').first().click()
|
||||
|
||||
// Verify filter applied
|
||||
cy.get('[data-cy="active-filters"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('navigates to mentioned item when clicked', () => {
|
||||
// Navigate to item with mention
|
||||
cy.clickMarker()
|
||||
cy.waitForPopup()
|
||||
|
||||
// Click mention
|
||||
cy.get('.item-mention').first().click()
|
||||
|
||||
// Verify navigation
|
||||
cy.url().should('include', '/item/')
|
||||
})
|
||||
|
||||
it('preserves markdown through edit-save cycle', () => {
|
||||
// Create item with formatting
|
||||
cy.get('[data-cy="create-item-button"]').click()
|
||||
cy.get('.ProseMirror').type('**Bold** and *italic* with #tag')
|
||||
cy.get('[data-cy="save-button"]').click()
|
||||
|
||||
// Edit item
|
||||
cy.get('[data-cy="edit-button"]').click()
|
||||
|
||||
// Verify markdown preserved
|
||||
cy.get('.ProseMirror strong').should('contain', 'Bold')
|
||||
cy.get('.ProseMirror em').should('contain', 'italic')
|
||||
cy.get('.ProseMirror .hashtag').should('contain', '#tag')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Error Handling
|
||||
|
||||
### Critical Edge Cases
|
||||
|
||||
All functions should handle these edge cases gracefully:
|
||||
|
||||
1. **Empty/Null Input** - `''`, `null`, `undefined` should not throw
|
||||
2. **Very Long Text** - Performance check with 10,000+ characters
|
||||
3. **Nested Syntax** - `**#bold-hashtag**`, `*[@mention](/item/x)*`
|
||||
4. **Concurrent Tokens** - `#tag1#tag2` (no space between)
|
||||
5. **Unicode** - Emojis 🎉, RTL text, umlauts (München), accents (café)
|
||||
6. **Malformed Markdown** - Unclosed tags: `**bold`, `[link(`
|
||||
7. **URLs in Code Blocks** - Should NOT be converted
|
||||
8. **XSS Vectors** - `<script>`, event handlers in links, `javascript:` URLs
|
||||
9. **Special Characters** - `<`, `>`, `&`, `"`, `'` in content must be escaped
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Vitest Unit Tests - Highest Priority
|
||||
|
||||
| File | Test Count | Purpose |
|
||||
|------|------------|---------|
|
||||
| `preprocessMarkdown.spec.ts` | ~45 tests | Pure function preprocessing pipeline |
|
||||
| `simpleMarkdownToHtml.spec.ts` | ~25 tests | HTML conversion, basic security |
|
||||
| `xss.spec.ts` | ~15 tests | Comprehensive XSS attack vectors |
|
||||
| `ReplaceURLs.spec.ts` | ~10 tests | Dependency functions (fixUrls, mailRegex) |
|
||||
|
||||
**Deliverable:** Core transformation logic + security fully covered
|
||||
|
||||
### Phase 2: Cypress Component Tests - TipTap Extensions
|
||||
|
||||
| File | Test Count | Purpose |
|
||||
|------|------------|---------|
|
||||
| `Hashtag.cy.tsx` | ~12 tests | Parse, style, behavior |
|
||||
| `ItemMention.cy.tsx` | ~12 tests | Parse, style, behavior |
|
||||
| `VideoEmbed.cy.tsx` | ~8 tests | Parse, render |
|
||||
| `contracts.cy.tsx` | ~6 tests | Preprocessing → TipTap integration |
|
||||
|
||||
**Deliverable:** All TipTap extensions verified in real browser
|
||||
|
||||
### Phase 3: Component Tests - Editors
|
||||
|
||||
| File | Tool | Test Count | Purpose |
|
||||
|------|------|------------|---------|
|
||||
| `RichTextEditor.cy.tsx` | Cypress | ~10 tests | Full editor with suggestions |
|
||||
| `TextView.cy.tsx` | Cypress | ~6 tests | Read-only TipTap |
|
||||
| `TextViewStatic.spec.ts` | Vitest | ~12 tests | Static HTML renderer (no TipTap) |
|
||||
|
||||
**Deliverable:** All render paths tested
|
||||
|
||||
### Phase 4: E2E Tests - Lowest Priority
|
||||
|
||||
| File | Test Count | Purpose |
|
||||
|------|------------|---------|
|
||||
| `rich-text.cy.ts` | 5 tests | Critical user journeys |
|
||||
|
||||
**Deliverable:** End-to-end user flows verified
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lib/src/Components/TipTap/
|
||||
├── utils/
|
||||
│ ├── preprocessMarkdown.ts
|
||||
│ ├── preprocessMarkdown.spec.ts # Vitest - pure functions
|
||||
│ ├── simpleMarkdownToHtml.tsx
|
||||
│ ├── simpleMarkdownToHtml.spec.ts # Vitest - pure functions
|
||||
│ └── xss.spec.ts # Vitest - security tests
|
||||
├── extensions/
|
||||
│ ├── Hashtag.tsx
|
||||
│ ├── Hashtag.cy.tsx # Cypress Component
|
||||
│ ├── ItemMention.tsx
|
||||
│ ├── ItemMention.cy.tsx # Cypress Component
|
||||
│ ├── VideoEmbed.tsx
|
||||
│ └── VideoEmbed.cy.tsx # Cypress Component
|
||||
└── __tests__/
|
||||
└── contracts.cy.tsx # Cypress Component - contract tests
|
||||
|
||||
lib/src/Components/Input/
|
||||
├── RichTextEditor.tsx
|
||||
└── RichTextEditor.cy.tsx # Cypress Component
|
||||
|
||||
lib/src/Components/Map/Subcomponents/ItemPopupComponents/
|
||||
├── TextView.tsx
|
||||
├── TextView.cy.tsx # Cypress Component
|
||||
├── TextViewStatic.tsx
|
||||
└── TextViewStatic.spec.ts # Vitest - no TipTap dependency
|
||||
|
||||
lib/src/Utils/
|
||||
└── ReplaceURLs.spec.ts # Vitest - dependency tests
|
||||
|
||||
cypress/e2e/tiptap/
|
||||
└── rich-text.cy.ts # Cypress E2E
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Test Distribution
|
||||
|
||||
| Test Type | Tool | Test Count | Coverage Target |
|
||||
|-----------|------|------------|-----------------|
|
||||
| **Vitest Unit Tests** | Vitest | ~95 | >90% for utility functions |
|
||||
| **Cypress Component Tests** | Cypress | ~54 | >80% for TipTap components |
|
||||
| **Vitest Component Tests** | Vitest | ~12 | >80% for TextViewStatic |
|
||||
| **E2E Tests** | Cypress | 5 | Critical paths only |
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Pure functions in Vitest** - `preprocessMarkdown` and `simpleMarkdownToHtml` are fast, isolated tests
|
||||
2. **TipTap components in Cypress** - Real browser avoids fragile jsdom mocks
|
||||
3. **Contract tests are critical** - Verify preprocessing output works with TipTap
|
||||
4. **Roundtrip tests are critical** - Markdown → Editor → Markdown must be lossless
|
||||
5. **Minimal E2E** - Only critical user journeys to avoid flaky tests
|
||||
6. **XSS prevention** - Dedicated security test suite is mandatory
|
||||
|
||||
### Benefits of This Strategy
|
||||
|
||||
- **Fast Feedback** - Vitest unit tests execute in <1 second
|
||||
- **Reliable TipTap Tests** - Cypress Component uses real browser (no mocking)
|
||||
- **High Maintainability** - Clear separation between Vitest and Cypress responsibilities
|
||||
- **Good Error Localization** - Isolated tests pinpoint failures
|
||||
- **Security Coverage** - Dedicated XSS test suite catches vulnerabilities
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Cypress Component Testing](https://docs.cypress.io/guides/component-testing/overview)
|
||||
- [TipTap Testing Discussion #4008](https://github.com/ueberdosis/tiptap/discussions/4008)
|
||||
- [TipTap Jest Issue #5108](https://github.com/ueberdosis/tiptap/issues/5108)
|
||||
- [TipTap Markdown Extension Docs](https://tiptap.dev/docs/extensions/extensions/markdown)
|
||||
Loading…
x
Reference in New Issue
Block a user