diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 4c8632e..4f594b0 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -55,8 +55,15 @@ jobs: run: npx playwright install chromium - name: Run E2E Tests + env: + PLAYWRIGHT_HTML_REPORT: playwright-report/e2e run: npm run test:e2e + - name: Run Visual Tests + env: + PLAYWRIGHT_HTML_REPORT: playwright-report/visual + run: npm run test:visual + - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 diff --git a/docs/SCREENSHOT_TESTS_DESIGN.md b/docs/SCREENSHOT_TESTS_DESIGN.md new file mode 100644 index 0000000..b828c26 --- /dev/null +++ b/docs/SCREENSHOT_TESTS_DESIGN.md @@ -0,0 +1,139 @@ +# Screenshot Testing Design Document + +## 1. Problem Statement + +Topola Viewer is a highly interactive, visual genealogy exploration tool that renders family trees using complex SVG layouts and D3 configurations. As the codebase evolves, minor updates to CSS styles, React components, or underlying layout algorithms can easily introduce subtle visual regressions—such as overlapping text labels, misaligned parent-child connector lines, or broken formatting in the side panels—that standard text-based DOM tests cannot detect. To prevent these visual bugs from reaching production, we are introducing an automated screenshot (visual regression) testing suite using Playwright. This testing suite will capture pixel-perfect snapshots of critical interface states, automatically flag unintended visual changes, and guarantee a consistently polished, premium user experience across all releases. + +## 2. The Technical Plan + +To consistently verify the user interface without introducing complex setups, the screenshot testing framework is built on a local-first, self-contained execution model. It operates by launching a virtual web browser, running the Topola Viewer application inside it, and checking it against stored master images (baselines). + +This setup consists of four major parts working in harmony: + +1. **The Test Orchestrator (Playwright):** This acts as the central manager. It starts our local web server, launches virtual browser instances, automates user actions (such as clicking buttons or navigation links), captures the screenshots, and does the pixel-by-pixel comparison against our baseline master images. +2. **The Local Web Server:** A background web server hosting the Topola Viewer application code. It serves the frontend interface directly to the virtual browser so that the test context runs identically to our actual user deployments. +3. **The Network Traffic Controller (Route Interceptor):** An in-memory network router managed by the orchestrator. When the browser attempts to download a genealogy file (e.g., `family.ged`) or load a person's photo, the router intercepts that request and immediately answers it with tiny, predefined test datasets (fixtures). This guarantees that the test runs completely offline, remains blazingly fast, and has absolute visual predictability. +4. **The Environment Sanitizer:** A tiny automated script executed directly inside the browser window right before a screenshot is snapped. Its only job is to locate and overwrite dynamic or shifting text elements (like Git commit hashes or changelog dates) with fixed placeholders, ensuring they do not trigger false test failures. + +## 3. Alternatives Considered & Rejected + +To prevent future developer friction, avoid redundant debugging cycles, and establish firm design guardrails, the following technical alternatives were evaluated and rejected: + +### Alternative A: Global Animation Freezing (`freeze=true` Query Parameter) +* **Considered:** Forcing Topola's SVG engine to completely freeze all animations globally in E2E tests to prevent visual capturing mismatches. +* **Why Rejected:** Topola Viewer's initial chart mounting is entirely static; D3 transitions are only triggered during interactive navigation (e.g., clicking to shift focus to a child node). Since the target snapshots capture the initial mount of a chart or isolated panel element, introducing a complex global animation freezing hook is redundant. Instead, utilizing Playwright's standard auto-waiting mechanism (which pauses until the SVG is fully loaded and stationary) provides flawless, stable captures naturally. + +### Alternative B: Monolithic Reference GEDCOM File (`rich_details.ged`) +* **Considered:** Maintaining a single, massive master `.ged` file containing a wide collection of custom individuals (with complex names, nested attributes, attached photos, and custom events) to serve all tests. +* **Why Rejected:** Monolithic test fixtures introduce severe coupling and high maintenance overhead. If a developer tweaks a birth record to debug an event-layout test, it can unintentionally shift elements in unrelated parts of the tree, failing baselines for name-formatting or photo rendering. Instead, creating microscopic, ad-hoc GEDCOM strings inline within each test case guarantees complete visual isolation, makes test intents instantly readable, and speeds up parsing. + +### Alternative C: Build-Time Environment Variable Overrides (Git SHA/Time) +* **Considered:** Overriding `VITE_GIT_TIME` and `VITE_GIT_SHA` at build time specifically for E2E testing. +* **Why Rejected:** In production gating pipelines (such as GitHub Actions), the application is built and packaged into production-ready assets before the E2E job begins execution. Re-compiling Vite assets solely to inject static E2E values is slow, resource-intensive, and violates the rule of testing the exact binary that will be deployed. Instead, executing an in-browser DOM override (`page.evaluate`) right before screenshot execution is lightweight, self-contained, and requires zero alterations to the build flow or production bundle. + +### Alternative D: Strict Pixel-Perfect Matching (Zero-Tolerance) +* **Considered:** Requiring absolute, 100% visual equivalence with zero pixel mismatch allowed. +* **Why Rejected:** Slight discrepancies in font rendering, subpixel antialiasing, and color blending are unavoidable across different operating systems (macOS developers vs. Linux CI agents). Enforcing zero-tolerance leads to extremely brittle tests that fail constantly due to harmless system-level rendering differences. Instead, setting relaxed thresholds (`maxDiffPixelRatio: 0.05` and `threshold: 0.2`) filters out system noise while aggressively catching genuine layout bugs, overlapping elements, and formatting failures. + +## 4. Detailed Implementation Plan + +This section defines the granular, step-by-step implementation steps and enumerates every file that will be created or modified to complete this visual regression framework. + +### A. Enumeration of Files + +#### 1. Files to [MODIFY] + +* **[playwright.config.ts](../playwright.config.ts)** + * *Rationale:* Isolate visual regression tests into a separate Playwright project (separate from standard functional E2E tests). This allows applying dedicated visual settings (like viewport locking, automatic scrollbar hiding, and custom screenshot mismatch thresholds) exclusively to visual tests without polluting standard E2E runs. Threshold settings are configured globally under `expect.toHaveScreenshot`. +* **[package.json](../package.json)** + * *Rationale:* Add dedicated npm script commands to target the standard E2E project (`--project=e2e`) and the isolated visual testing project (`--project=visual`), preventing slow screenshot tests from bloating standard developer verification cycles. + +#### 2. Files to [NEW] + +* **`tests/helpers.ts`** + * *Rationale:* Provide shared E2E/visual testing helper utilities. Features `blockTracking()` to abort external Google Analytics and Google Tag Manager network requests (ensuring offline hermetic execution) and `setupGedcomRoute()` to serve a standard mock `.ged` dataset. +* **`tests/intro_visual.spec.ts`** + * *Rationale:* Verify the landing page layout, copy block positions, and logo alignments. Employs an in-browser DOM script to overwrite dynamic footer versioning and dynamic changelog blocks prior to capture, ensuring baseline immunity. +* **`tests/charts_visual.spec.ts`** + * *Rationale:* Verify chart canvas boundaries, nodes, colors, and connections. Iterates over three of the supported layouts (`Hourglass`, `Relatives`, `Donatso`) using a simple tree, and captures screenshots of the stabilized D3 canvas. +* **`tests/details_visual.spec.ts`** + * *Rationale:* Verify details panel formats, image margins, fact headers, and sources. Defines tiny, ad-hoc mock GEDCOM inline strings for individual edge cases (long multi-part names, attached images, nested events) and serves pre-existing photo assets (e.g. `docker/examples/photos/photos/I1.jpg`) to render photos without broken image layouts. +* **`tests/config_visual.spec.ts`** + * *Rationale:* Verify the visual synchronization between Side Panel settings checkboxes/radio inputs and the SVG canvas. Captures full-viewport screenshots (at 1280x720) across the three curated configuration combinations. + +### B. Step-by-Step Execution Plan + +#### Step 1: Visual Project Isolation & Script Provisioning +1. Open `playwright.config.ts` and configure separate projects within the projects array: + * Define an `e2e` project using desktop Chrome settings that matches all `.spec.ts` files (`testMatch`) but excludes `*_visual.spec.ts` files (`testIgnore`). + * Define a dedicated `visual` project that matches only `*_visual.spec.ts` files (`testMatch`), and locks the browser viewport to a width of `1280` and height of `720` pixels in the `use` configuration. +2. Configure custom visual expectation thresholds globally under `expect.toHaveScreenshot` (specifically setting `maxDiffPixelRatio` to `0.05`, `threshold` to `0.2`, and `animations` to `'disabled'`). +3. Open `package.json` and update the scripts to target standard and visual projects respectively: + * `"test:e2e": "playwright test --project=e2e"` to run functional E2E tests exclusively. + * `"test:visual": "playwright test --project=visual"` to run visual regression tests exclusively. + * `"test:visual:update": "playwright test --project=visual --update-snapshots"` to automatically regenerate baseline reference files. + +#### Step 2: Landing Page Visual Validation Spec (`tests/intro_visual.spec.ts`) +1. Define a test block marked with the `@visual` tag, utilizing `blockTracking` helper in `beforeEach`. +2. Instruct the browser to navigate to the root path `/`. +3. Right before assertion, trigger `page.evaluate` to clean dynamic elements: + * Target the `.version` class element and set `.innerText = "version: 2026-01-01 00:00 (testcommit)"`. + * Target the changelog element (the container immediately following the "What's new" heading) and replace its HTML with a static placeholder change entry. +4. Snap the screenshot using `expect(page).toHaveScreenshot('intro-page.png')`. + +#### Step 3: Core SVG Canvas Layouts Spec (`tests/charts_visual.spec.ts`) +1. Set up a `beforeEach` block that initializes `setupGedcomRoute(context)` from `helpers.ts` to intercept `**/family.ged` requests and fulfill them with raw GEDCOM test data. +2. Write tests with the `@visual` tag iterating through the 3 supported layouts (`hourglass`, `relatives`, `donatso`): + * Set browser route to `/#/view?url=https://example.org/family.ged&view=[hourglass|relatives|donatso]`. + * Determine the appropriate container selector: `#dotatsoSvgContainer` if the view is `donatso`, otherwise `#svgContainer`. + * Locate the container element, and call `locator.waitFor()` to ensure the element is fully attached and visible. + * Wait for D3 rendering and layout stabilization using a brief layout-specific timeout (`waitTime`: `500ms` for hourglass/relatives, `1500ms` for donatso). + * Capture the isolated canvas screenshot: `expect(container).toHaveScreenshot('chart-[type].png')`. + +#### Step 4: Details Panel Layouts Spec (`tests/details_visual.spec.ts`) +1. Set up a `beforeEach` block to block analytics via `blockTracking(context)`. +2. Define isolated test blocks with the `@visual` tag, each loading its own dedicated inline micro-GEDCOM dataset: + * **Complex Names Test:** + * Mock `**/family.ged` with a GEDCOM string containing prefix/suffix/rufname tags. + * Navigate to the view route with `sidePanel=true`, locate the side panel container `#sidebar`. + * Assert sidebar visual representation: `expect(page.locator('#sidebar')).toHaveScreenshot('details-complex-name.png')`. + * **Image / Photo Rendering Test:** + * Mock `**/family.ged` containing an `OBJE` tag pointing to a photo path (e.g. `photos/I1.jpg`). + * Intercept requests for `**/photos/I1.jpg` and fulfill the request by serving the project asset `docker/examples/photos/photos/I1.jpg`. + * Navigate, wait for the image load handler to complete (`img.waitFor({state: 'visible'})` and checking `image.complete` status). + * Assert sidebar visual representation: `expect(page.locator('#sidebar')).toHaveScreenshot('details-photo-render.png')`. + * **Custom Facts & Citations Test:** + * Mock `**/family.ged` containing complex nested fact (`FACT`), source (`SOUR`), and note (`NOTE`) trees. + * Select the individual and wait for `#sidebar` to load. + * Assert sidebar visual representation: `expect(page.locator('#sidebar')).toHaveScreenshot('details-events-sources.png')`. + +#### Step 5: Configurations Integration Spec (`tests/config_visual.spec.ts`) +1. Define a test block tagged `@visual` with a locked browser window viewport size of `1280x720` via `playwright.config.ts`. +2. In `beforeEach`, mock `**/family.ged` using `setupGedcomRoute(context)`, load `/view?sidePanel=true`, wait for `#sidebar` and `#content` to be visible, and click the "Settings" tab (`await page.getByText('Settings', {exact: true}).click();`) to expose config fields. +3. Assert the **Default Configuration (State 1)**: + * Verify that both the checkbox states and the corresponding generation-colored SVG boxes are in alignment. + * Assert the entire integrated screen: `expect(page).toHaveScreenshot('config-state-default.png')`. +4. Automate panel clicks: Scope locators using `page.locator('form.details .item')` to target the "Colors" and "IDs" section items. Select the "by sex" color radio button and select the "hide" IDs option. +5. Wait for updates (`page.waitForTimeout(300)`) and assert the **Sex Colors & No IDs Configuration (State 2)**: + * Assert the entire integrated screen: `expect(page).toHaveScreenshot('config-state-gender-no-ids.png')`. +6. Automate panel clicks: Scope locators using `page.locator('form.details .item')` to target the "Colors" and "Sex" section items. Select the "none" color radio button and select the "hide" sex option. +7. Wait for updates (`page.waitForTimeout(300)`) and assert the **Minimalist Configuration (State 3)**: + * Assert the entire integrated screen: `expect(page).toHaveScreenshot('config-state-minimalist.png')`. + +## 5. CI/CD Pipeline Integration + +To ensure that no visual regressions are introduced into the master branch, the visual testing suite is integrated into the GitHub Actions CI/CD workflow ([node.js.yml](../.github/workflows/node.js.yml)) alongside existing tests. + +### Pipeline Configuration +Visual tests run sequentially after standard E2E tests. The workflow executes the following steps: +1. **Install Dependencies:** Resolves Node.js package dependencies and installs/caches Playwright browser binaries (specifically `chromium`). +2. **Run E2E Tests:** Runs standard functional tests using `npm run test:e2e`. +3. **Run Visual Tests:** Runs visual regression tests using `npm run test:visual`. + +### Playwright HTML Reports in CI +To prevent test reports from overwriting each other, the HTML reports for the different testing suites are output to distinct subdirectories within the `playwright-report` folder: +* **E2E Tests:** Saved to `playwright-report/e2e` by setting the `PLAYWRIGHT_HTML_REPORT` environment variable. +* **Visual Tests:** Saved to `playwright-report/visual` by setting the `PLAYWRIGHT_HTML_REPORT` environment variable. + +Both reports are bundled and uploaded as a single workflow artifact (`playwright-report-${{ matrix.node-version }}`) on completion, allowing easy review of failures. + diff --git a/package-lock.json b/package-lock.json index 694b53d..71e1e18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-react": "^4.3.4", + "dedent": "^1.7.2", "eslint": "^7.32.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -4450,7 +4451,9 @@ } }, "node_modules/dedent": { - "version": "1.5.3", + "version": "1.7.2", + "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 66621af..0974191 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-react": "^4.3.4", + "dedent": "^1.7.2", "eslint": "^7.32.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -93,8 +94,11 @@ "predeploy-wikitree": "npm run build", "deploy-wikitree": "./deploy-wikitree.sh", "preview": "vite preview", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e": "playwright test --project=e2e", + "test:e2e:ui": "playwright test --project=e2e --ui", + "test:visual": "playwright test --project=visual", + "test:visual:update": "playwright test --project=visual --update-snapshots", + "test:visual:ui": "playwright test --project=visual --ui" }, "homepage": ".", "browserslist": [ diff --git a/playwright.config.ts b/playwright.config.ts index 3d735bf..321d16e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,13 +12,29 @@ export default defineConfig({ locale: 'en-US', // Forces consistent translation keys across locales for robust placeholder selectors trace: 'on-first-retry', }, + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.05, + threshold: 0.2, + animations: 'disabled', + }, + }, projects: [ { - name: 'chromium', + name: 'e2e', + testIgnore: '*_visual.spec.ts', use: { ...devices['Desktop Chrome'], }, }, + { + name: 'visual', + testMatch: '*_visual.spec.ts', + use: { + ...devices['Desktop Chrome'], + viewport: {width: 1280, height: 720}, + }, + }, ], webServer: { command: process.env.CI diff --git a/tests/chart_view.spec.ts b/tests/chart_view.spec.ts index aff9f9a..5f29e24 100644 --- a/tests/chart_view.spec.ts +++ b/tests/chart_view.spec.ts @@ -4,7 +4,7 @@ import {setupGedcomRoute} from './helpers'; test.describe('Chart view', () => { test.beforeEach(async ({page, context}) => { await setupGedcomRoute(context); - await page.goto('/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged'); + await page.goto('/#/view?url=https://example.org/family.ged'); }); test('loads data from URL', async ({page}) => { diff --git a/tests/charts_visual.spec.ts b/tests/charts_visual.spec.ts new file mode 100644 index 0000000..a02bf53 --- /dev/null +++ b/tests/charts_visual.spec.ts @@ -0,0 +1,27 @@ +import {expect, test} from '@playwright/test'; +import {setupGedcomRoute} from './helpers'; + +test.describe('Core SVG Canvas Layouts @visual', () => { + test.beforeEach(async ({page, context}) => { + await setupGedcomRoute(context); + }); + + const layouts = [ + {view: 'hourglass', selector: '#svgContainer', waitTime: 500}, + {view: 'relatives', selector: '#svgContainer', waitTime: 500}, + {view: 'donatso', selector: '#dotatsoSvgContainer', waitTime: 1500}, + ]; + + for (const layout of layouts) { + test(`chart-${layout.view}`, async ({page}) => { + await page.goto( + `/#/view?url=https://example.org/family.ged&view=${layout.view}`, + ); + const container = page.locator(layout.selector); + await container.waitFor({state: 'visible'}); + // Wait for D3 rendering and layout stabilization. + await page.waitForTimeout(layout.waitTime); + await expect(container).toHaveScreenshot(`chart-${layout.view}.png`); + }); + } +}); diff --git a/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-linux.png b/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-linux.png new file mode 100644 index 0000000..5f9a798 Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-donatso-visual-linux.png differ diff --git a/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-linux.png b/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-linux.png new file mode 100644 index 0000000..340d9bf Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-hourglass-visual-linux.png differ diff --git a/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-linux.png b/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-linux.png new file mode 100644 index 0000000..0bfc5eb Binary files /dev/null and b/tests/charts_visual.spec.ts-snapshots/chart-relatives-visual-linux.png differ diff --git a/tests/config_visual.spec.ts b/tests/config_visual.spec.ts new file mode 100644 index 0000000..16b355a --- /dev/null +++ b/tests/config_visual.spec.ts @@ -0,0 +1,59 @@ +import {expect, test} from '@playwright/test'; +import {setupGedcomRoute} from './helpers'; + +test.describe('Configurations Integration @visual', () => { + test.beforeEach(async ({page, context}) => { + await setupGedcomRoute(context); + await page.goto('/#/view?url=https://example.org/family.ged'); + + // Wait for the sidebar and the main content container to be visible. + const sidebar = page.locator('#sidebar'); + const mainContent = page.locator('#content'); + await sidebar.waitFor(); + await mainContent.waitFor(); + + // Switch to the Settings/Config tab in the side panel + await page.getByText('Settings', {exact: true}).click(); + }); + + test('Default Configuration (State 1)', async ({page}) => { + // Assert the Default Configuration (State 1). + await expect(page).toHaveScreenshot('config-state-default.png'); + }); + + test('Sex Colors & No IDs Configuration (State 2)', async ({page}) => { + // Locate the section containers inside form.details specifically to avoid Info tab ambiguity. + const colorsSection = page + .locator('form.details .item') + .filter({hasText: 'Colors'}); + const idsSection = page + .locator('form.details .item') + .filter({hasText: 'IDs'}); + + // Toggle "by sex" colors and "hide" IDs. + await colorsSection.getByText('by sex').click(); + await idsSection.getByText('hide').click(); + + // Wait a brief moment for SVG rendering to update. + await page.waitForTimeout(300); + await expect(page).toHaveScreenshot('config-state-gender-no-ids.png'); + }); + + test('Minimalist Configuration (State 3)', async ({page}) => { + // Locate the section containers inside form.details specifically to avoid Info tab ambiguity. + const colorsSection = page + .locator('form.details .item') + .filter({hasText: 'Colors'}); + const sexSection = page + .locator('form.details .item') + .filter({hasText: 'Sex'}); + + // Toggle "none" colors and "hide" sex labels. + await colorsSection.getByText('none').click(); + await sexSection.getByText('hide').click(); + + // Wait a brief moment for SVG rendering to update. + await page.waitForTimeout(300); + await expect(page).toHaveScreenshot('config-state-minimalist.png'); + }); +}); diff --git a/tests/config_visual.spec.ts-snapshots/config-state-default-visual-linux.png b/tests/config_visual.spec.ts-snapshots/config-state-default-visual-linux.png new file mode 100644 index 0000000..a5aa651 Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-default-visual-linux.png differ diff --git a/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-linux.png b/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-linux.png new file mode 100644 index 0000000..f594c49 Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-gender-no-ids-visual-linux.png differ diff --git a/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-linux.png b/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-linux.png new file mode 100644 index 0000000..e6c0fe8 Binary files /dev/null and b/tests/config_visual.spec.ts-snapshots/config-state-minimalist-visual-linux.png differ diff --git a/tests/details_visual.spec.ts b/tests/details_visual.spec.ts new file mode 100644 index 0000000..8a23a3d --- /dev/null +++ b/tests/details_visual.spec.ts @@ -0,0 +1,138 @@ +import {expect, test} from '@playwright/test'; +import dedent from 'dedent'; +import * as fs from 'fs'; +import {blockTracking, mockGedcomResponse} from './helpers'; + +test.describe('Details panel visual validation @visual', () => { + test.beforeEach(async ({context}) => { + await blockTracking(context); + }); + + test('Complex Names Test', async ({page, context}) => { + const complexNameGedcom = dedent` + 0 HEAD + 1 GEDC + 2 VERS 5.5.1 + 2 FORM Lineage-Linked + 1 CHAR UTF-8 + 0 @I1@ INDI + 1 NAME Dr. Bonifacy "Boni" /Gibbs/ III + 2 NPFX Dr. + 2 GIVN Bonifacy + 2 NICK Boni + 2 SURN Gibbs + 2 NSFX III + 2 _RUFNAME Bonifacy + 1 SEX M + 1 FAMS @F1@ + 0 @F1@ FAM + 1 HUSB @I1@ + 0 TRLR + `; + + await mockGedcomResponse(context, complexNameGedcom); + + await page.goto('/#/view?url=https://example.org/family.ged'); + const sidebar = page.locator('#sidebar'); + await sidebar.waitFor(); + await expect(sidebar).toHaveScreenshot('details-complex-name.png'); + }); + + test('Image / Photo Rendering Test', async ({page, context}) => { + const photoGedcom = dedent` + 0 HEAD + 1 GEDC + 2 VERS 5.5.1 + 2 FORM Lineage-Linked + 1 CHAR UTF-8 + 0 @I1@ INDI + 1 NAME Bonifacy /Gibbs/ + 1 SEX M + 1 FAMS @F1@ + 1 OBJE @O1@ + 0 @O1@ OBJE + 1 FILE http://example.org/photos/I1.jpg + 2 FORM jpeg + 0 @F1@ FAM + 1 HUSB @I1@ + 0 TRLR + `; + + await mockGedcomResponse(context, photoGedcom); + + await context.route('**/photos/I1.jpg', async (route) => { + const imageBuffer = fs.readFileSync( + 'docker/examples/photos/photos/I1.jpg', + ); + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: imageBuffer, + }); + }); + + await page.goto('/#/view?url=https://example.org/family.ged'); + const sidebar = page.locator('#sidebar'); + await sidebar.waitFor(); + + // Wait for image loading to complete + const img = sidebar.locator('img').first(); + await img.waitFor({state: 'visible'}); + await img.evaluate((image) => { + return new Promise((resolve, reject) => { + if ((image as HTMLImageElement).complete) resolve(true); + image.addEventListener('load', () => resolve(true)); + image.addEventListener('error', () => + reject(new Error('Image failed to load')), + ); + }); + }); + + await expect(sidebar).toHaveScreenshot('details-photo-render.png'); + }); + + test('Custom Facts & Citations Test', async ({page, context}) => { + const customFactsGedcom = dedent` + 0 HEAD + 1 GEDC + 2 VERS 5.5.1 + 2 FORM Lineage-Linked + 1 CHAR UTF-8 + 0 @I1@ INDI + 1 NAME Bonifacy /Gibbs/ + 1 SEX M + 1 FAMS @F1@ + 1 FACT Custom fact data + 2 TYPE Custom Fact Type + 2 SOUR @S1@ + 3 PAGE 42 + 3 DATA + 4 DATE 12 JAN 1850 + 2 NOTE This is a note nested under a custom fact. + 1 BIRT + 2 DATE 1 JAN 1800 + 2 PLAC Paris, France + 2 SOUR @S1@ + 3 PAGE 10 + 2 NOTE Birth event note. + 1 DEAT + 2 DATE 31 DEC 1880 + 2 PLAC London, UK + 1 NOTE This is a top-level note for the individual. + 0 @S1@ SOUR + 1 TITL Great Genealogy Book + 1 AUTH John Doe + 1 PUBL London Publishing, 1890 + 0 @F1@ FAM + 1 HUSB @I1@ + 0 TRLR + `; + + await mockGedcomResponse(context, customFactsGedcom); + + await page.goto('/#/view?url=https://example.org/family.ged'); + const sidebar = page.locator('#sidebar'); + await sidebar.waitFor(); + await expect(sidebar).toHaveScreenshot('details-events-sources.png'); + }); +}); diff --git a/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-linux.png b/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-linux.png new file mode 100644 index 0000000..86fcb85 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-complex-name-visual-linux.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-linux.png b/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-linux.png new file mode 100644 index 0000000..c41a6e4 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-events-sources-visual-linux.png differ diff --git a/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-linux.png b/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-linux.png new file mode 100644 index 0000000..0a7c000 Binary files /dev/null and b/tests/details_visual.spec.ts-snapshots/details-photo-render-visual-linux.png differ diff --git a/tests/helpers.ts b/tests/helpers.ts index 6838292..bbb9b70 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -10,6 +10,23 @@ export async function blockTracking(context: BrowserContext): Promise { await context.route('**/*googletagmanager.com/**', (route) => route.abort()); } +/** + * Mocks the endpoint for GEDCOM file requests using the provided GEDCOM content string. + */ +export async function mockGedcomResponse( + context: BrowserContext, + gedcomContent: string, +): Promise { + await context.route('**/family.ged', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/plain', + headers: {'Access-Control-Allow-Origin': '*'}, + body: gedcomContent, + }); + }); +} + /** * Sets up interception for raw GEDCOM requests, fulfills them with cached test data * and sets up CORS proxy checks and analytics blocking. @@ -20,14 +37,6 @@ export async function setupGedcomRoute(context: BrowserContext): Promise { 'utf-8', ); - await context.route('**/family.ged', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/plain', - headers: {'Access-Control-Allow-Origin': '*'}, - body: gedcomContent, - }); - }); - + await mockGedcomResponse(context, gedcomContent); await blockTracking(context); } diff --git a/tests/intro_visual.spec.ts b/tests/intro_visual.spec.ts new file mode 100644 index 0000000..b4592ca --- /dev/null +++ b/tests/intro_visual.spec.ts @@ -0,0 +1,37 @@ +import {expect, test} from '@playwright/test'; +import {blockTracking} from './helpers'; + +test.describe('Intro page visual validation @visual', () => { + test.beforeEach(async ({page, context}) => { + await blockTracking(context); + await page.goto('/'); + }); + + test('intro-page', async ({page}) => { + // Clean dynamic elements right before snapping the screenshot. + await page.evaluate(() => { + // 1. Overwrite dynamic footer versioning. + const versionEl = document.querySelector('.version'); + if (versionEl) { + (versionEl as HTMLElement).innerText = + 'version: 2026-01-01 00:00 (testcommit)'; + } + + // 2. Replace dynamic changelog block with static placeholder. + const headers = Array.from(document.querySelectorAll('h3')); + const whatsNewHeader = headers.find((h) => + h.textContent?.includes("What's new"), + ); + if (whatsNewHeader) { + const changelogSpan = whatsNewHeader.nextElementSibling; + if (changelogSpan) { + changelogSpan.innerHTML = + '

2026-01-01

  • Placeholder change entry
'; + } + } + }); + + // Snap the screenshot. + await expect(page).toHaveScreenshot('intro-page.png'); + }); +}); diff --git a/tests/intro_visual.spec.ts-snapshots/intro-page-visual-linux.png b/tests/intro_visual.spec.ts-snapshots/intro-page-visual-linux.png new file mode 100644 index 0000000..3969d43 Binary files /dev/null and b/tests/intro_visual.spec.ts-snapshots/intro-page-visual-linux.png differ diff --git a/tests/search.spec.ts b/tests/search.spec.ts index 74ec2fb..ddfa3ae 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -7,7 +7,7 @@ test.describe('Search functionality', () => { }); test('Search works', async ({page}) => { - await page.goto('/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged'); + await page.goto('/#/view?url=https://example.org/family.ged'); await expect(page.locator('#content')).not.toContainText('Chike'); const searchInput = page.getByPlaceholder('Search for people'); diff --git a/tests/webmcp.spec.ts b/tests/webmcp.spec.ts index e66affe..4b49bf0 100644 --- a/tests/webmcp.spec.ts +++ b/tests/webmcp.spec.ts @@ -32,7 +32,7 @@ test.describe('WebMCP Integration', () => { }); test('registers tools to standard modelContext', async ({page}) => { - await page.goto('/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged'); + await page.goto('/#/view?url=https://example.org/family.ged'); // Polling assertion to avoid React useEffect registration race condition. await page.waitForFunction( @@ -49,7 +49,7 @@ test.describe('WebMCP Integration', () => { }); test('allows running focus_indi tool', async ({page}) => { - await page.goto('/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged'); + await page.goto('/#/view?url=https://example.org/family.ged'); await page.waitForFunction( (expectedCount) => window.__registeredTools?.length === expectedCount, EXPECTED_TOOL_NAMES.length,