From 85df7d13bb9fa9ae7c51c6dde16ac9e2f4423f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Sun, 5 Jul 2026 22:51:04 +0200 Subject: [PATCH] Format markdown files --- CHANGELOG.md | 12 +- PROJECT_STRUCTURE.md | 101 +++- README.md | 142 +++-- docs/APP_REFACTORING_DESIGN.md | 299 ++++++++-- docs/DOCKER_DESIGN.md | 614 +++++++++++++------ docs/IMMEDIATE_FAMILY_SECTION_DESIGN.md | 232 ++++++-- docs/LOAD_FROM_GOOGLE_DRIVE.md | 754 +++++++++++++++++------- docs/PLAYWRIGHT_DESIGN.md | 540 ++++++++++++----- docs/PROBERS_DESIGN.md | 668 +++++++++++---------- docs/README.md | 34 +- docs/SCREENSHOT_TESTS_DESIGN.md | 336 ++++++++--- docs/SEARCH_SHORTCUT_DESIGN.md | 201 +++++-- docs/UPLOADED_IMAGES_IN_DETAILS.md | 359 ++++++++--- docs/WEBMCP_DESIGN.md | 399 +++++++++---- package.json | 2 +- prettier.config.mjs | 9 + src/README.md | 76 ++- src/datasource/README.md | 41 +- src/menu/README.md | 31 +- src/sidepanel/README.md | 36 +- src/sidepanel/config/README.md | 20 +- src/sidepanel/details/README.md | 39 +- src/sidepanel/head/README.md | 11 +- src/translations/README.md | 27 +- src/util/README.md | 40 +- tests/README.md | 99 +++- tests/probers/README.md | 21 +- 27 files changed, 3602 insertions(+), 1541 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc191f4..28ca329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,18 @@ ## 2026-04-20 -- Shift-click to show a person's details without rearranging the tree (by FrankBuchholz) +- Shift-click to show a person's details without rearranging the tree (by + FrankBuchholz) ## 2026-02-21 -- Improved saving PDF files. Decreased file size and increased chart size that can be saved as PDF. +- Improved saving PDF files. Decreased file size and increased chart size that + can be saved as PDF. ## 2026-02-13 -- Show header information of the gedcom file on the side panel (by FrankBuchholz) +- Show header information of the gedcom file on the side panel (by + FrankBuchholz) ## 2026-01-19 @@ -30,7 +33,8 @@ ## 2025-01-21 -- Added new chart type based on [github.com/donatso/family-chart](https://github.com/donatso/family-chart) +- Added new chart type based on + [github.com/donatso/family-chart](https://github.com/donatso/family-chart) ## 2023-08-25 diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index f30d329..fa34356 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -1,56 +1,93 @@ # Project Structure -This directory is the root directory of the **Topola Genealogy Viewer** project. It serves as the central hub containing project configuration, build setup, dependency management, and the main entry points for development, testing, and deployment. The actual application source code, assets, and tests are organized into subdirectories. +This directory is the root directory of the **Topola Genealogy Viewer** project. +It serves as the central hub containing project configuration, build setup, +dependency management, and the main entry points for development, testing, and +deployment. The actual application source code, assets, and tests are organized +into subdirectories. ## Purpose of this Directory -The root directory orchestrates the project. It contains configuration files for various tools (Vite, ESLint, Jest, Playwright, TypeScript), metadata about dependencies (`package.json`), and scripts for common tasks. It glues together the source code in `src`, the static assets in `public`, and the tests in `tests`. +The root directory orchestrates the project. It contains configuration files for +various tools (Vite, ESLint, Jest, Playwright, TypeScript), metadata about +dependencies (`package.json`), and scripts for common tasks. It glues together +the source code in `src`, the static assets in `public`, and the tests in +`tests`. ## Files in this Directory (Categorized by Role) -Here is an enumeration of the files in this directory, categorized by their role in the project: +Here is an enumeration of the files in this directory, categorized by their role +in the project: ### Application Entry Point -* **[index.html](index.html)**: The main HTML entry point for the application. + +- **[index.html](index.html)**: The main HTML entry point for the application. ### Build & Environment -* **[.env](.env)**: Contains environment variables for the project. -* **[vite.config.mts](vite.config.mts)**: Configuration file for the Vite build tool. + +- **[.env](.env)**: Contains environment variables for the project. +- **[vite.config.mts](vite.config.mts)**: Configuration file for the Vite build + tool. ### Dependencies & Scripts -* **[package.json](package.json)**: Defines the project's dependencies, scripts, version, and other metadata. -* **[package-lock.json](package-lock.json)**: Automatically generated file that describes the exact dependency tree that was generated by npm. -* **[deploy-wikitree.sh](deploy-wikitree.sh)**: A shell script used to deploy the application files to WikiTree servers using `lftp`. + +- **[package.json](package.json)**: Defines the project's dependencies, scripts, + version, and other metadata. +- **[package-lock.json](package-lock.json)**: Automatically generated file that + describes the exact dependency tree that was generated by npm. +- **[deploy-wikitree.sh](deploy-wikitree.sh)**: A shell script used to deploy + the application files to WikiTree servers using `lftp`. ### Tool Configuration -* **[.eslintrc.js](.eslintrc.js)**: Configuration file for ESLint, used for identifying and reporting on patterns in JavaScript/TypeScript code. -* **[.gitignore](.gitignore)**: Specifies files and directories that should be ignored by Git. -* **[prettier.config.mjs](prettier.config.mjs)**: Configuration for Prettier code formatter. -* **[tsconfig.json](tsconfig.json)**: Configuration file for the TypeScript compiler. + +- **[.eslintrc.js](.eslintrc.js)**: Configuration file for ESLint, used for + identifying and reporting on patterns in JavaScript/TypeScript code. +- **[.gitignore](.gitignore)**: Specifies files and directories that should be + ignored by Git. +- **[prettier.config.mjs](prettier.config.mjs)**: Configuration for Prettier + code formatter. +- **[tsconfig.json](tsconfig.json)**: Configuration file for the TypeScript + compiler. ### Testing -* **[playwright.config.ts](playwright.config.ts)**: Configuration file for Playwright end-to-end testing framework. -* **[playwright.prober.config.ts](playwright.prober.config.ts)**: Separate Playwright config for prober (live smoke) tests against deployed URLs. -* **[jest.config.ts](jest.config.ts)**: Configuration file for the Jest testing framework used for unit tests. + +- **[playwright.config.ts](playwright.config.ts)**: Configuration file for + Playwright end-to-end testing framework. +- **[playwright.prober.config.ts](playwright.prober.config.ts)**: Separate + Playwright config for prober (live smoke) tests against deployed URLs. +- **[jest.config.ts](jest.config.ts)**: Configuration file for the Jest testing + framework used for unit tests. ### Documentation & Assets -* **[README.md](README.md)**: The main project documentation, covering features, examples, and usage instructions. -* **[CHANGELOG.md](CHANGELOG.md)**: Records a log of notable changes made to the project over time. -* **[docs/README.md](docs/README.md)**: Registry for system design files describing containerization, AI protocol interfaces, Playwright integration, and screenshot testing. -* **[LICENSE](LICENSE)**: The license file for the project (Apache License 2.0). -* **[screenshot.png](screenshot.png)**: An image showing a screenshot of the application, used in the README.md. + +- **[README.md](README.md)**: The main project documentation, covering features, + examples, and usage instructions. +- **[CHANGELOG.md](CHANGELOG.md)**: Records a log of notable changes made to the + project over time. +- **[docs/README.md](docs/README.md)**: Registry for system design files + describing containerization, AI protocol interfaces, Playwright integration, + and screenshot testing. +- **[LICENSE](LICENSE)**: The license file for the project (Apache License 2.0). +- **[screenshot.png](screenshot.png)**: An image showing a screenshot of the + application, used in the README.md. ## Subdirectories Overview -Based on the documentation in the subdirectories, here is a high-level overview of the project's structure: +Based on the documentation in the subdirectories, here is a high-level overview +of the project's structure: -* **[docs](docs)**: High-level technical and architectural design documents. -* **[src](src)**: The core of the application. - * **[datasource](src/datasource)**: Handles loading and transforming genealogical data from various sources (files, URLs, WikiTree). - * **[menu](src/menu)**: Contains navigation and menu components. - * **[sidepanel](src/sidepanel)**: Implements the side panel for details and configuration. - * **[translations](src/translations)**: Holds localization JSON files. - * **[util](src/util)**: Common utilities for dates, analytics, and data processing. -* **[public](public)**: Contains static assets (like the favicon) served directly at the root path. -* **[tests](tests)**: Contains end-to-end tests. - * **[tests/probers](tests/probers)**: Live smoke tests (probers) that run against deployed URLs and external services. +- **[docs](docs)**: High-level technical and architectural design documents. +- **[src](src)**: The core of the application. + - **[datasource](src/datasource)**: Handles loading and transforming + genealogical data from various sources (files, URLs, WikiTree). + - **[menu](src/menu)**: Contains navigation and menu components. + - **[sidepanel](src/sidepanel)**: Implements the side panel for details and + configuration. + - **[translations](src/translations)**: Holds localization JSON files. + - **[util](src/util)**: Common utilities for dates, analytics, and data + processing. +- **[public](public)**: Contains static assets (like the favicon) served + directly at the root path. +- **[tests](tests)**: Contains end-to-end tests. + - **[tests/probers](tests/probers)**: Live smoke tests (probers) that run + against deployed URLs and external services. diff --git a/README.md b/README.md index fa6cda5..5ffaab4 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,19 @@ If you find this project useful, consider buying me a coffee. [![buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/pewu) ## Features -* Hourglass chart -* All relatives chart -* Click on a person to focus -* Open standard GEDCOM files you can export from any genealogy application -* Load from URL (just point to a GEDCOM file on the Web) -* Privacy – your files do not leave your computer -* Print the whole genealogy tree -* Export to PDF, PNG, SVG -* Side panel with details -* Configuration options -* Permalinks when loading from URL -* Cool transition animations + +- Hourglass chart +- All relatives chart +- Click on a person to focus +- Open standard GEDCOM files you can export from any genealogy application +- Load from URL (just point to a GEDCOM file on the Web) +- Privacy – your files do not leave your computer +- Print the whole genealogy tree +- Export to PDF, PNG, SVG +- Side panel with details +- Configuration options +- Permalinks when loading from URL +- Cool transition animations [Changelog](CHANGELOG.md) @@ -36,62 +37,82 @@ If you find this project useful, consider buying me a coffee. Here is an example from the Web: -* [J. F. Kennedy](https://pewu.github.io/topola-viewer/#/view?url=https%3A%2F%2Fchronoplexsoftware.com%2Fmyfamilytree%2Fsamples%2FThe%2520Kennedy%2520Family.gdz) (from [chronoplexsoftware.com](https://chronoplexsoftware.com/myfamilytree/samples/)) -* [Shakespeare](https://pewu.github.io/topola-viewer/#/view?url=https%3A%2F%2Fwebtreeprint.com%2Ftp_downloader.php%3Fpath%3Dfamous_gedcoms%2Fshakespeare.ged%26file%3Dshakespeare.ged) (from [webtreeprint.com](https://webtreeprint.com/tp_famous_gedcoms.php)) -* [Maria Skłodowska-Curie](https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?indi=Sk%C5%82odowska-2&source=wikitree) (from [WikiTree](https://www.wikitree.com/wiki/Sk%C5%82odowska-2)) +- [J. F. Kennedy](https://pewu.github.io/topola-viewer/#/view?url=https%3A%2F%2Fchronoplexsoftware.com%2Fmyfamilytree%2Fsamples%2FThe%2520Kennedy%2520Family.gdz) + (from + [chronoplexsoftware.com](https://chronoplexsoftware.com/myfamilytree/samples/)) +- [Shakespeare](https://pewu.github.io/topola-viewer/#/view?url=https%3A%2F%2Fwebtreeprint.com%2Ftp_downloader.php%3Fpath%3Dfamous_gedcoms%2Fshakespeare.ged%26file%3Dshakespeare.ged) + (from [webtreeprint.com](https://webtreeprint.com/tp_famous_gedcoms.php)) +- [Maria Skłodowska-Curie](https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?indi=Sk%C5%82odowska-2&source=wikitree) + (from [WikiTree](https://www.wikitree.com/wiki/Sk%C5%82odowska-2)) -If you have data in a genealogy database, you can export your data in GEDCOM format and load it using the "Load from file" menu. +If you have data in a genealogy database, you can export your data in GEDCOM +format and load it using the "Load from file" menu. ## Supported file formats Topola Genealogy Viewer supports the following file formats: -* **`.ged`** – Standard GEDCOM file containing genealogical data. -* **`.gdz`** / **`.gedzip`** / **`.zip`** – A ZIP archive that bundles a GEDCOM file together with associated media (photos, documents). This is useful when your family tree references image files and you want them to display in the chart. +- **`.ged`** – Standard GEDCOM file containing genealogical data. +- **`.gdz`** / **`.gedzip`** / **`.zip`** – A ZIP archive that bundles a GEDCOM + file together with associated media (photos, documents). This is useful when + your family tree references image files and you want them to display in the + chart. ### GEDZIP archives -A `.gdz` file is a standard ZIP archive. When you load one, Topola Viewer automatically unzips it in the browser (your data never leaves your computer), finds the first `.ged` file inside and uses it as the genealogy data, and treats all other files as images. Image file paths are normalized (backslashes converted to forward slashes, case lowered) so they match the references in the GEDCOM. +A `.gdz` file is a standard ZIP archive. When you load one, Topola Viewer +automatically unzips it in the browser (your data never leaves your computer), +finds the first `.ged` file inside and uses it as the genealogy data, and treats +all other files as images. Image file paths are normalized (backslashes +converted to forward slashes, case lowered) so they match the references in the +GEDCOM. -To create a `.gdz` archive, place your `.ged` file and image files (e.g. in a `photos/` folder) into a directory and zip them together: +To create a `.gdz` archive, place your `.ged` file and image files (e.g. in a +`photos/` folder) into a directory and zip them together: ```bash zip -r family.gdz family.ged photos/ ``` -The resulting `family.gdz` file can be loaded via "Load from file", loaded from a URL, or mounted into a Docker container (see [Docker Container Deployment](#docker-container-deployment)). +The resulting `family.gdz` file can be loaded via "Load from file", loaded from +a URL, or mounted into a Docker container (see +[Docker Container Deployment](#docker-container-deployment)). ## Integrations -Topola Genealogy Viewer is being integrated into more and more Web and desktop applications. -Here are the current integrations: +Topola Genealogy Viewer is being integrated into more and more Web and desktop +applications. Here are the current integrations: ### Gramps -To view your [Gramps](https://gramps-project.org/) data in Topola Genealogy Viewer, -install [*Interactive Family Tree*](https://gramps-project.org/wiki/index.php/Interactive_Family_Tree) -plugin from the Gramps plugin manager. The plugin will add a -*Tools->Analysis and Exploration->Interactive Family Tree* menu item to Gramps. +To view your [Gramps](https://gramps-project.org/) data in Topola Genealogy +Viewer, install +[_Interactive Family Tree_](https://gramps-project.org/wiki/index.php/Interactive_Family_Tree) +plugin from the Gramps plugin manager. The plugin will add a _Tools->Analysis +and Exploration->Interactive Family Tree_ menu item to Gramps. Source code: https://github.com/gramps-project/addons-source/tree/master/Topola ### Webtrees -Embed Topola Genealogy Viewer in your [Webtrees](https://www.webtrees.net/) installation with the +Embed Topola Genealogy Viewer in your [Webtrees](https://www.webtrees.net/) +installation with the [Topola interactive tree addon](https://webtrees.net/download/modules#simple-auto-login---by-fanningert---20---website). Source code: https://github.com/PeWu/topola-webtrees ### WikiTree -You can browse the [WikiTree](https://www.wikitree.com/) genealogy tree using Topola Genealogy Viewer. -On a WikiTree profile page go to the *Family Tree & Tools* tab and click the *Dynamic Tree by Topola* link. +You can browse the [WikiTree](https://www.wikitree.com/) genealogy tree using +Topola Genealogy Viewer. On a WikiTree profile page go to the _Family Tree & +Tools_ tab and click the _Dynamic Tree by Topola_ link. Example: [Stephen Hawking](https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&standalone=false&indi=Hawking-7) -Topola Genealogy Viewer is hosted on [apps.wikitree.com](https://apps.wikitree.com/apps/wiech13/topola-viewer) -to benefit from the ability of being logged in to the WikiTree API. +Topola Genealogy Viewer is hosted on +[apps.wikitree.com](https://apps.wikitree.com/apps/wiech13/topola-viewer) to +benefit from the ability of being logged in to the WikiTree API. ## Running locally @@ -102,19 +123,25 @@ npm start ## Self-hosting -You can host Topola Genealogy Viewer on your own server. There are no specific requirements for the hosting server. There is no code that is executed on the server side. The server only hosts the application files and whole application runs in the browser. +You can host Topola Genealogy Viewer on your own server. There are no specific +requirements for the hosting server. There is no code that is executed on the +server side. The server only hosts the application files and whole application +runs in the browser. -You can build Topola Genealogy Viewer from source code or take a ready-to-deploy package. +You can build Topola Genealogy Viewer from source code or take a ready-to-deploy +package. ### Bulid your own Here are the commands to build the application: + ``` git clone https://github.com/PeWu/topola-viewer.git cd topola-viewer npm install npm run build ``` + Now, take the contents of the `dist/` folder and host it on your own server. ### Use an existing package @@ -126,21 +153,27 @@ These are the exact files that are hosted on GitHub pages. ### Build for your own data only -You can run Topola Viewer in a "single tree mode" that displays only the GEDCOM you specify. Specify the URL to a GEDCOM file in the `VITE_STATIC_URL` environment variable when building and running the application. +You can run Topola Viewer in a "single tree mode" that displays only the GEDCOM +you specify. Specify the URL to a GEDCOM file in the `VITE_STATIC_URL` +environment variable when building and running the application. Run locally with the specified data URL: + ```shell VITE_STATIC_URL=https://example.org/sample.ged npm start ``` +
For Windows CMD: ```cmd set VITE_STATIC_URL=https://example.org/sample.ged && npm run build ``` +
Build with the specified data URL: + ```shell VITE_STATIC_URL=https://example.org/sample.ged npm run build ``` @@ -151,13 +184,15 @@ VITE_STATIC_URL=https://example.org/sample.ged npm run build ```cmd set VITE_STATIC_URL=https://example.org/sample.ged && npm run build ``` + The `dist/` folder will contain files that can be hosted on a Web server. ### Build without Google Analytics -Set `VITE_GOOGLE_ANALYTICS=false` to exclude Google Analytics from the build output. This will remove the external JavaScript dependency. +Set `VITE_GOOGLE_ANALYTICS=false` to exclude Google Analytics from the build +output. This will remove the external JavaScript dependency. ```shell VITE_GOOGLE_ANALYTICS=false npm run build @@ -169,27 +204,38 @@ VITE_GOOGLE_ANALYTICS=false npm run build ```cmd set VITE_GOOGLE_ANALYTICS=false && npm run build ``` + This may be combined with the other build environment variables described above. ### Alternative build -The [topola-webpack](https://github.com/develancer/topola-webpack) tool can build a Topola Genealogy Viewer package bundled together with a GEDCOM file. +The [topola-webpack](https://github.com/develancer/topola-webpack) tool can +build a Topola Genealogy Viewer package bundled together with a GEDCOM file. ## Docker Container Deployment -Topola Viewer can be run locally or deployed to standard cloud environments using Docker. +Topola Viewer can be run locally or deployed to standard cloud environments +using Docker. ### Running Topola Viewer + To pull and run Topola Viewer: + ```bash docker run -d -p 8080:8080 ghcr.io/pewu/topola-viewer:latest ``` -Open your web browser and go to `http://localhost:8080` to upload your family tree files locally. + +Open your web browser and go to `http://localhost:8080` to upload your family +tree files locally. ### Running with Your Own Data (Zero-Build Run) -You can serve a standalone, pre-loaded family tree with zero compilation by mounting your family tree data (a `.ged` file or a zipped `.gdz` archive containing photos) directly into the running container: + +You can serve a standalone, pre-loaded family tree with zero compilation by +mounting your family tree data (a `.ged` file or a zipped `.gdz` archive +containing photos) directly into the running container: + ```bash docker run -d -p 8080:8080 \ -e STATIC_URL=my_family.gdz \ @@ -198,16 +244,22 @@ docker run -d -p 8080:8080 \ ``` ### Building the Base Image Locally + To build the base image from source: + ```bash docker build -t topola-viewer -f docker/Dockerfile . ``` ### Ready-To-Use Standalone Templates -For creating completely self-contained Docker images that bundle your genealogy data and serve it instantly, see these pre-configured examples: -1. **[Simple Standalone Tree](docker/examples/simple/)**: Demonstrates how to package and pre-load a `.ged` file directly inside a custom image. -2. **[Standalone Tree with Photos](docker/examples/photos/)**: Packages your family tree and a `photos/` folder into a valid `.gdz` archive on-the-fly. +For creating completely self-contained Docker images that bundle your genealogy +data and serve it instantly, see these pre-configured examples: + +1. **[Simple Standalone Tree](docker/examples/simple/)**: Demonstrates how to + package and pre-load a `.ged` file directly inside a custom image. +2. **[Standalone Tree with Photos](docker/examples/photos/)**: Packages your + family tree and a `photos/` folder into a valid `.gdz` archive on-the-fly. ## Additional options @@ -217,4 +269,6 @@ Add `&handleCors=false` to the URL to avoid using the CORS proxy ### `embedded` -Add `&embedded=true` to the URL. This option removes the options to open a different file. It is an option that was intended to be used when Topola Genealogy Viewer is in an iframe. +Add `&embedded=true` to the URL. This option removes the options to open a +different file. It is an option that was intended to be used when Topola +Genealogy Viewer is in an iframe. diff --git a/docs/APP_REFACTORING_DESIGN.md b/docs/APP_REFACTORING_DESIGN.md index 89406b9..b362864 100644 --- a/docs/APP_REFACTORING_DESIGN.md +++ b/docs/APP_REFACTORING_DESIGN.md @@ -1,118 +1,303 @@ # Refactoring App.tsx Design Document ## Problem Description -The main application component, [App](../src/app.tsx), has grown into a large and complex monolith that coordinates URL routing, query parameter synchronization, asynchronous data fetching, UI layout rendering, and external integrations like the WebMCP bridge. Because these distinct responsibilities are combined within a single file, the codebase has become difficult to navigate, test, and safely modify without introducing unintended regressions. This refactoring effort aims to systematically decouple these concerns by decomposing the monolith into small, single-responsibility modules, separating the page-level UI layouts, and establishing the URL query parameters as the single source of truth for viewer state. + +The main application component, [App](../src/app.tsx), has grown into a large +and complex monolith that coordinates URL routing, query parameter +synchronization, asynchronous data fetching, UI layout rendering, and external +integrations like the WebMCP bridge. Because these distinct responsibilities are +combined within a single file, the codebase has become difficult to navigate, +test, and safely modify without introducing unintended regressions. This +refactoring effort aims to systematically decouple these concerns by decomposing +the monolith into small, single-responsibility modules, separating the +page-level UI layouts, and establishing the URL query parameters as the single +source of truth for viewer state. ## The Technical Plan -At a high level, the refactored architecture splits user interface representation from business logic and external connections. Instead of a single central orchestrator holding all states, the app relies on the browser's URL to drive the active view and delegates sub-features (like data fetching, authentication, and external syncing) to dedicated hooks and services. +At a high level, the refactored architecture splits user interface +representation from business logic and external connections. Instead of a single +central orchestrator holding all states, the app relies on the browser's URL to +drive the active view and delegates sub-features (like data fetching, +authentication, and external syncing) to dedicated hooks and services. ### Explanatory Overview -* **Routing & Page Views:** The root [App](../src/app.tsx) component will serve purely as a routing switch, but will retain the Google Drive "Open with" flow redirection logic. It decides whether to display the landing screen (`IntroPage`) or the visual family tree explorer (`ViewPage`). It is also responsible for stripping external authorization query parameters (like `state` and `authcode`) from the browser window's URL. -* **Layout Nesting (TopBar & SidePanel):** Rather than having a global layout shell wrapping all pages, each page will compose its own layout explicitly. This allows the `ViewPage` to pass live chart selection data, configuration, and export triggers directly to its own rendered `TopBar`, while the `IntroPage` remains clean and completely decoupled from viewer states. To prevent locking the user interface on loading errors, `ViewPage` will render the `TopBar` even when in loading or error states. -* **URL as Single Source of Truth (SSOT):** Rather than keeping user settings (like the selected person, active chart view, and display preferences) in temporary React memory and continuously syncing them to the URL, the application will read these properties directly from the URL query string on every render. This includes the detail selection sidebar state (`detailIndi` will map to `?detail=...`). Changing a setting now simply updates the browser's URL, and React automatically updates the view. -* **Data Service Layer:** All logic involved in downloading, caching, and parsing GEDCOM or WikiTree files from different sources is fully isolated from the user interface, triggered via a unified API spec on the `ViewPage`. -* **WebMCP & Auth Hooks:** Third-party integrations and security layers (like the WebMCP developer bridge and Google Drive sign-in states) are extracted into custom React hooks. These hooks manage side effects independently, removing clutter from the layout rendering code. +- **Routing & Page Views:** The root [App](../src/app.tsx) component will serve + purely as a routing switch, but will retain the Google Drive "Open with" flow + redirection logic. It decides whether to display the landing screen + (`IntroPage`) or the visual family tree explorer (`ViewPage`). It is also + responsible for stripping external authorization query parameters (like + `state` and `authcode`) from the browser window's URL. +- **Layout Nesting (TopBar & SidePanel):** Rather than having a global layout + shell wrapping all pages, each page will compose its own layout explicitly. + This allows the `ViewPage` to pass live chart selection data, configuration, + and export triggers directly to its own rendered `TopBar`, while the + `IntroPage` remains clean and completely decoupled from viewer states. To + prevent locking the user interface on loading errors, `ViewPage` will render + the `TopBar` even when in loading or error states. +- **URL as Single Source of Truth (SSOT):** Rather than keeping user settings + (like the selected person, active chart view, and display preferences) in + temporary React memory and continuously syncing them to the URL, the + application will read these properties directly from the URL query string on + every render. This includes the detail selection sidebar state (`detailIndi` + will map to `?detail=...`). Changing a setting now simply updates the + browser's URL, and React automatically updates the view. +- **Data Service Layer:** All logic involved in downloading, caching, and + parsing GEDCOM or WikiTree files from different sources is fully isolated from + the user interface, triggered via a unified API spec on the `ViewPage`. +- **WebMCP & Auth Hooks:** Third-party integrations and security layers (like + the WebMCP developer bridge and Google Drive sign-in states) are extracted + into custom React hooks. These hooks manage side effects independently, + removing clutter from the layout rendering code. ## Alternatives Considered ### 1. Global State / Prop Drilling in Root Component -* **Approach:** Keep all the viewer and configuration state inside the root `App` component and pass them down as props to the nested components (such as `IntroPage` and `ViewPage`). -* **Why Rejected:** This would fail to address the core problem. The root `app.tsx` file would remain a massive state-managing monolith containing dozens of hooks and effects, making it just as hard to maintain and test as before. + +- **Approach:** Keep all the viewer and configuration state inside the root + `App` component and pass them down as props to the nested components (such as + `IntroPage` and `ViewPage`). +- **Why Rejected:** This would fail to address the core problem. The root + `app.tsx` file would remain a massive state-managing monolith containing + dozens of hooks and effects, making it just as hard to maintain and test as + before. ### 2. React Context for Viewer Session -* **Approach:** Create a global React Context (e.g., `GenealogySessionContext`) that holds the active viewer's data, parameters, and callback handlers, and wrap the application root in a Context Provider. -* **Why Rejected:** While a valid pattern, introducing Context adds boilerplate and can lead to unnecessary re-renders. Explicit page-level nesting—where `ViewPage` and `IntroPage` each explicitly render their own `TopBar` headers—is a simpler and more isolated design. It allows components to consume direct, local props without global state providers. + +- **Approach:** Create a global React Context (e.g., `GenealogySessionContext`) + that holds the active viewer's data, parameters, and callback handlers, and + wrap the application root in a Context Provider. +- **Why Rejected:** While a valid pattern, introducing Context adds boilerplate + and can lead to unnecessary re-renders. Explicit page-level nesting—where + `ViewPage` and `IntroPage` each explicitly render their own `TopBar` + headers—is a simpler and more isolated design. It allows components to consume + direct, local props without global state providers. ### 3. React-State-to-URL Syncing (Bidirectional Sync) -* **Approach:** Maintain React state variables (`useState`) for the selected individual, chart view type, and configuration, and keep them synchronized with the URL query parameters using `useEffect` loops. -* **Why Rejected:** Bidirectional synchronization is extremely error-prone and a frequent cause of infinite rendering loops and race conditions (especially with async network requests). Transitioning to the **URL as the Single Source of Truth** (deriving all active states directly from the URL on render) completely removes the sync hooks and ensures browser back/forward buttons work correctly out of the box. + +- **Approach:** Maintain React state variables (`useState`) for the selected + individual, chart view type, and configuration, and keep them synchronized + with the URL query parameters using `useEffect` loops. +- **Why Rejected:** Bidirectional synchronization is extremely error-prone and a + frequent cause of infinite rendering loops and race conditions (especially + with async network requests). Transitioning to the **URL as the Single Source + of Truth** (deriving all active states directly from the URL on render) + completely removes the sync hooks and ensures browser back/forward buttons + work correctly out of the box. ### 4. Component-First Refactoring (Extracting Visual Leaf Nodes) -* **Approach:** Start by extracting pure UI layout leaf nodes (such as the chart display wrappers or sidebar layouts) before addressing the business logic. -* **Why Rejected:** The primary complexity in `app.tsx` lies in the lifecycle management and side effects, not the JSX markup size. Starting with leaf components leaves the complex state orchestrations untouched. Instead, we prioritized extracting utility modules, isolated background hooks (like `WebMcpBridge`), and transitioning states to the URL before separating UI pages. + +- **Approach:** Start by extracting pure UI layout leaf nodes (such as the chart + display wrappers or sidebar layouts) before addressing the business logic. +- **Why Rejected:** The primary complexity in `app.tsx` lies in the lifecycle + management and side effects, not the JSX markup size. Starting with leaf + components leaves the complex state orchestrations untouched. Instead, we + prioritized extracting utility modules, isolated background hooks (like + `WebMcpBridge`), and transitioning states to the URL before separating UI + pages. ## Detailed Implementation -To refactor `app.tsx` safely and maintain continuous code correctness, we will modify one file and create eight new ones. This section maps out the role of each file and the step-by-step implementation plan. +To refactor `app.tsx` safely and maintain continuous code correctness, we will +modify one file and create eight new ones. This section maps out the role of +each file and the step-by-step implementation plan. ### Affected Files and Rationale #### 1. [MODIFY] `src/app.tsx` -* **Role:** Will be stripped down to a lightweight Router component that only defines page-level paths, coordinates Google Drive "Open with" redirection synchronously on render, and renders `` or ``. -* **Rationale:** Eliminates the monolithic coordinator by delegating view orchestration, state synchronization, and page rendering to dedicated page components and hooks, while retaining initial payload interception. + +- **Role:** Will be stripped down to a lightweight Router component that only + defines page-level paths, coordinates Google Drive "Open with" redirection + synchronously on render, and renders `` or ``. +- **Rationale:** Eliminates the monolithic coordinator by delegating view + orchestration, state synchronization, and page rendering to dedicated page + components and hooks, while retaining initial payload interception. #### 2. [NEW] `src/components/error_display.tsx` -* **Role:** Will contain the presentational components `ErrorMessage` and `ErrorPopup`. -* **Rationale:** Keeps simple UI leaf components separated from the main business logic and state coordinates. + +- **Role:** Will contain the presentational components `ErrorMessage` and + `ErrorPopup`. +- **Rationale:** Keeps simple UI leaf components separated from the main + business logic and state coordinates. #### 3. [NEW] `src/datasource/instances.ts` -* **Role:** Declares and exports global datasource class instances (`uploadedDataSource`, `gedcomUrlDataSource`, `embeddedDataSource`, `googleDriveDataSource`). -* **Rationale:** Avoids declaring stateful data service instances inside layout and view components, improving separation of concerns. + +- **Role:** Declares and exports global datasource class instances + (`uploadedDataSource`, `gedcomUrlDataSource`, `embeddedDataSource`, + `googleDriveDataSource`). +- **Rationale:** Avoids declaring stateful data service instances inside layout + and view components, improving separation of concerns. #### 4. [NEW] `src/util/url_args.ts` -* **Role:** Exports `getStaticUrl`, `getParamFromSearch`, and `getArguments` utility functions, along with their related TypeScript interfaces (`Arguments`, `DataSourceSpec`), including support for the `detail` query parameter. It also provides a pure helper function (`getUrlForArgs`) that accepts current/new arguments and returns a path/query object suitable for passing to React Router's `navigate` function. -* **Rationale:** Moves parsing and decoding logic for query strings out of the React components, allowing this code to be tested independently in isolation. + +- **Role:** Exports `getStaticUrl`, `getParamFromSearch`, and `getArguments` + utility functions, along with their related TypeScript interfaces + (`Arguments`, `DataSourceSpec`), including support for the `detail` query + parameter. It also provides a pure helper function (`getUrlForArgs`) that + accepts current/new arguments and returns a path/query object suitable for + passing to React Router's `navigate` function. +- **Rationale:** Moves parsing and decoding logic for query strings out of the + React components, allowing this code to be tested independently in isolation. #### 5. [NEW] `src/util/url_args.spec.ts` -* **Role:** Jest unit tests verifying the correctness of `getArguments` under different query configurations (e.g. WikiTree authcodes, Google Drive file IDs, static URLs, local uploads). -* **Rationale:** URL parsing acts as the gateway to all loader code; ensuring this logic is 100% correct via unit testing prevents regressions during URL SSOT changes. + +- **Role:** Jest unit tests verifying the correctness of `getArguments` under + different query configurations (e.g. WikiTree authcodes, Google Drive file + IDs, static URLs, local uploads). +- **Rationale:** URL parsing acts as the gateway to all loader code; ensuring + this logic is 100% correct via unit testing prevents regressions during URL + SSOT changes. #### 6. [NEW] `src/hooks/use_webmcp_bridge.ts` -* **Role:** Custom React hook `useWebMcpBridge(data, detailIndi, onSelection)` called in `ViewPage` encapsulating the WebMCP bridge creation, tool registration, state synchronization, and cleanup effects. -* **Rationale:** Isolates external developer tools syncing from the UI components. + +- **Role:** Custom React hook `useWebMcpBridge(data, detailIndi, onSelection)` + called in `ViewPage` encapsulating the WebMCP bridge creation, tool + registration, state synchronization, and cleanup effects. +- **Rationale:** Isolates external developer tools syncing from the UI + components. #### 7. [NEW] `src/hooks/use_google_auth.ts` -* **Role:** Custom React hook `useGoogleAuth()` encapsulating access to the global `googleDriveService` authentication token, session storage cache clearing, and login/logout trigger states. -* **Rationale:** Provides shared authentication state that can be consumed by the `TopBar` headers of both page components without needing parent state lifting. + +- **Role:** Custom React hook `useGoogleAuth()` encapsulating access to the + global `googleDriveService` authentication token, session storage cache + clearing, and login/logout trigger states. +- **Rationale:** Provides shared authentication state that can be consumed by + the `TopBar` headers of both page components without needing parent state + lifting. #### 8. [NEW] `src/pages/view_page.tsx` -* **Role:** The page view orchestrating the chart visualizer workspace. It handles asynchronous loader triggers, keeps local data loading states, derives view configurations from the URL, and renders the Sidebar, SidePanel, and its own explicit `TopBar` (including rendering on loading/error states). It also hosts the state and rendering for `` to handle Google Drive auth errors caught during data loading, handles memoization of `WikiTreeDataSource` (due to its react-intl context requirement), performs Object URL cleanup on unmount, and runs `updateChartWithConfig` synchronously during the render pass to synchronize URL-derived configuration settings with the in-memory mutated chart data. -* **Rationale:** Consolidates the chart workspace state and render tree away from the root router component. + +- **Role:** The page view orchestrating the chart visualizer workspace. It + handles asynchronous loader triggers, keeps local data loading states, derives + view configurations from the URL, and renders the Sidebar, SidePanel, and its + own explicit `TopBar` (including rendering on loading/error states). It also + hosts the state and rendering for `` to handle Google Drive + auth errors caught during data loading, handles memoization of + `WikiTreeDataSource` (due to its react-intl context requirement), performs + Object URL cleanup on unmount, and runs `updateChartWithConfig` synchronously + during the render pass to synchronize URL-derived configuration settings with + the in-memory mutated chart data. +- **Rationale:** Consolidates the chart workspace state and render tree away + from the root router component. #### 9. [NEW] `src/pages/intro_page.tsx` -* **Role:** The landing page component wrapping the `Intro` presentation and rendering its own simple `TopBar`. -* **Rationale:** Decouples the landing screen structure from the chart visualizer's stateful layout shell. + +- **Role:** The landing page component wrapping the `Intro` presentation and + rendering its own simple `TopBar`. +- **Rationale:** Decouples the landing screen structure from the chart + visualizer's stateful layout shell. --- ### Step-by-Step Execution Plan #### Phase 1: Pure Component and Utility Extraction -* [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`. -* [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts. -* [x] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify. -* [x] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties. + +- [x] **Step 1.1:** Create `src/components/error_display.tsx` and move + `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in + `src/app.tsx`. +- [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source + class instantiations. Update references in `src/app.tsx`. Refactor + `EmbeddedDataSource` to clean up its message event listener when the + loading promise resolves or rejects (or track listener state) to prevent + duplicate event listener leaks on multiple page mounts. +- [x] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and + `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract + URL query parameter parsing functions and types from `src/app.tsx`, write + comprehensive tests, update imports in `src/app.tsx`, and run `npm test` + to verify. +- [x] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props + and event handlers optional (e.g. `data`, `allowAllRelativesChart`, + `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component + for rendering on the landing screen without dummy properties. #### Phase 2: WebMCP Bridge Extraction -* [x] **Step 2.1:** Create `src/hooks/use_webmcp_bridge.ts` wrapping WebMCP registration and synchronization effects. -* [x] **Step 2.2:** Replace the inline WebMCP logic and `useEffect` blocks in `src/app.tsx` with a single call to the custom `useWebMcpBridge` hook. Verify using `npm test`. Note: During Phase 4, this hook call will be moved from `src/app.tsx` into `src/pages/view_page.tsx` where the layout state is relocated. + +- [x] **Step 2.1:** Create `src/hooks/use_webmcp_bridge.ts` wrapping WebMCP + registration and synchronization effects. +- [x] **Step 2.2:** Replace the inline WebMCP logic and `useEffect` blocks in + `src/app.tsx` with a single call to the custom `useWebMcpBridge` hook. + Verify using `npm test`. Note: During Phase 4, this hook call will be + moved from `src/app.tsx` into `src/pages/view_page.tsx` where the layout + state is relocated. #### Phase 3: Shifting to URL as Single Source of Truth -We will gradually eliminate React state variables in `src/app.tsx` and derive them (including settings like `standalone`, `showWikiTreeMenus`, and `freezeAnimation`) directly from the `useLocation()` search query parameters on render: -* [x] **Step 3.1a:** Shift `chartType` to URL as SSOT. Derive it using `useMemo` from search parameters, replace calls to `setChartType` with URL updates, and remove the React state variable and sync effect. -* [x] **Step 3.1b:** Shift `standalone` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. -* [x] **Step 3.1c:** Shift `showWikiTreeMenus` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. -* [x] **Step 3.1d:** Shift `freezeAnimation` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. -* [x] **Step 3.2:** Extract `selection` and `detailIndi` state. Parse them directly from URL params (`indi`, `gen`, and `detail`); update display selectors. Remove React states. Ensure that chart selection changes (`onSelection` callback) explicitly clear or update the `detail` query parameter to match the new selection to avoid getting stuck on the old details viewport. Also update the detail-only selection handler (`onDetailSelection`) to update the `detail` query parameter in the URL. -* [x] **Step 3.3:** Extract `showSidePanel` state. Derive state directly from `?sidePanel=` parameter. Remove React state. Enhance URL args helpers to allow generating path/query target objects with replaced values, and ensure layout settings (`sidePanel`) and configuration changes use `replace: true` to prevent polluting the browser history stack. -* [x] **Step 3.4:** Extract `config` state. Parse display settings on render using `argsToConfig` helper. Remove state. In `ViewPage`, run `updateChartWithConfig(config, data)` synchronously during the render pass (e.g. in the `useMemo` that derives query parameters/config) to update the in-memory chart data before it is rendered by the `` child component. Run E2E and visual tests to verify no rendering regressions occurred. + +We will gradually eliminate React state variables in `src/app.tsx` and derive +them (including settings like `standalone`, `showWikiTreeMenus`, and +`freezeAnimation`) directly from the `useLocation()` search query parameters on +render: + +- [x] **Step 3.1a:** Shift `chartType` to URL as SSOT. Derive it using `useMemo` + from search parameters, replace calls to `setChartType` with URL updates, + and remove the React state variable and sync effect. +- [x] **Step 3.1b:** Shift `standalone` to URL as SSOT. Derive it directly from + search parameters on render and remove its React state variable. +- [x] **Step 3.1c:** Shift `showWikiTreeMenus` to URL as SSOT. Derive it + directly from search parameters on render and remove its React state + variable. +- [x] **Step 3.1d:** Shift `freezeAnimation` to URL as SSOT. Derive it directly + from search parameters on render and remove its React state variable. +- [x] **Step 3.2:** Extract `selection` and `detailIndi` state. Parse them + directly from URL params (`indi`, `gen`, and `detail`); update display + selectors. Remove React states. Ensure that chart selection changes + (`onSelection` callback) explicitly clear or update the `detail` query + parameter to match the new selection to avoid getting stuck on the old + details viewport. Also update the detail-only selection handler + (`onDetailSelection`) to update the `detail` query parameter in the URL. +- [x] **Step 3.3:** Extract `showSidePanel` state. Derive state directly from + `?sidePanel=` parameter. Remove React state. Enhance URL args helpers to + allow generating path/query target objects with replaced values, and + ensure layout settings (`sidePanel`) and configuration changes use + `replace: true` to prevent polluting the browser history stack. +- [x] **Step 3.4:** Extract `config` state. Parse display settings on render + using `argsToConfig` helper. Remove state. In `ViewPage`, run + `updateChartWithConfig(config, data)` synchronously during the render pass + (e.g. in the `useMemo` that derives query parameters/config) to update the + in-memory chart data before it is rendered by the `` child + component. Run E2E and visual tests to verify no rendering regressions + occurred. #### Phase 4: Structural Page Nesting -* [x] **Step 4.1:** Create `src/hooks/use_google_auth.ts` to manage Google Drive authentication token updates. Remove `hasGoogleToken` state from `src/app.tsx`. -* [x] **Step 4.2:** Create `src/pages/view_page.tsx` containing layout rendering (`renderMainArea`), asynchronous data loaders, view state management, `revokeObjectUrls` cleanups, and `` error fallback state and rendering. -* [x] **Step 4.3:** Create `src/pages/intro_page.tsx` rendering the landing screen and its own local header. -* [x] **Step 4.4:** Refactor `src/app.tsx` to serve as a pure routing switch routing to `` or ``. In `App`, handle Google Drive redirection synchronously during the render pass by returning a `` element (preventing render flashing), and ensure the redirection parser checks both the router `location.search` and the external `window.location.search` to handle HashRouter query structure robustly. Once external parameters (like `state` or `authcode`) are detected, they must be stripped from `window.location.search` using `window.history.replaceState` to prevent redirection loops on page refresh. Remove all layout state logic from the root file, including moving the `useWebMcpBridge` hook invocation into ``. Run final verification scripts (`npm run check:all`). + +- [x] **Step 4.1:** Create `src/hooks/use_google_auth.ts` to manage Google Drive + authentication token updates. Remove `hasGoogleToken` state from + `src/app.tsx`. +- [x] **Step 4.2:** Create `src/pages/view_page.tsx` containing layout rendering + (`renderMainArea`), asynchronous data loaders, view state management, + `revokeObjectUrls` cleanups, and `` error fallback state + and rendering. +- [x] **Step 4.3:** Create `src/pages/intro_page.tsx` rendering the landing + screen and its own local header. +- [x] **Step 4.4:** Refactor `src/app.tsx` to serve as a pure routing switch + routing to `` or ``. In `App`, handle Google + Drive redirection synchronously during the render pass by returning a + `` element (preventing render flashing), and ensure + the redirection parser checks both the router `location.search` and the + external `window.location.search` to handle HashRouter query structure + robustly. Once external parameters (like `state` or `authcode`) are + detected, they must be stripped from `window.location.search` using + `window.history.replaceState` to prevent redirection loops on page + refresh. Remove all layout state logic from the root file, including + moving the `useWebMcpBridge` hook invocation into ``. Run + final verification scripts (`npm run check:all`). ## Future Considerations ### 1. Image Object URL Memory Management -* Instead of managing image revocation inside the component lifecycle (which can break back-navigation since the `ViewPage` unmounts and revokes URLs), we will explore having the global datasource singletons in `instances.ts` (such as `UploadedDataSource` and `GoogleDriveDataSource`) manage the lifetime of their image URL maps. When `loadData` is called on a datasource, it will check if it has a reference to a previous image map and explicitly revoke it before loading the new dataset. + +- Instead of managing image revocation inside the component lifecycle (which can + break back-navigation since the `ViewPage` unmounts and revokes URLs), we will + explore having the global datasource singletons in `instances.ts` (such as + `UploadedDataSource` and `GoogleDriveDataSource`) manage the lifetime of their + image URL maps. When `loadData` is called on a datasource, it will check if it + has a reference to a previous image map and explicitly revoke it before + loading the new dataset. ### 2. TopBar Layout Refactoring -* Refactor `top_bar.tsx` to make all chart-specific event handlers and properties optional, or split the header layout from the stateful workspace menus. This will eliminate passing dummy or empty handlers from the `IntroPage`. - - - +- Refactor `top_bar.tsx` to make all chart-specific event handlers and + properties optional, or split the header layout from the stateful workspace + menus. This will eliminate passing dummy or empty handlers from the + `IntroPage`. diff --git a/docs/DOCKER_DESIGN.md b/docs/DOCKER_DESIGN.md index acbdfc5..3dc5538 100644 --- a/docs/DOCKER_DESIGN.md +++ b/docs/DOCKER_DESIGN.md @@ -2,79 +2,164 @@ ## 1. Problem Description -Topola Viewer is a modern, client-side web application designed for exploring and visualizing genealogy data from GEDCOM files. Currently, running a custom instance with a pre-loaded family tree and photos requires manual local development builds or setting up complex web hosting environments. This project aims to package Topola Viewer inside an ultra-lightweight, secure, and production-ready Docker container. This container will allow users to instantly deploy a vanilla viewer or easily serve their own private, self-contained family trees with zero local compilation or build-tool dependencies. +Topola Viewer is a modern, client-side web application designed for exploring +and visualizing genealogy data from GEDCOM files. Currently, running a custom +instance with a pre-loaded family tree and photos requires manual local +development builds or setting up complex web hosting environments. This project +aims to package Topola Viewer inside an ultra-lightweight, secure, and +production-ready Docker container. This container will allow users to instantly +deploy a vanilla viewer or easily serve their own private, self-contained family +trees with zero local compilation or build-tool dependencies. ## 2. Major Components & How They Interact -At a high level, the containerized Topola Viewer consists of three primary components working together to serve and display family trees: +At a high level, the containerized Topola Viewer consists of three primary +components working together to serve and display family trees: -1. **The User's Web Browser (The App)**: This is where Topola Viewer actually runs. Since it is a single-page React application, the browser downloads the application files once and executes all the logic, chart rendering, and user interactions locally on the user's computer. -2. **The Caddy Web Server (The Helper)**: This is a lightweight, secure, and fast background server running inside the Docker container. Its job is to serve the application files (HTML, CSS, JavaScript) and the family tree package when the browser requests them. Additionally, Caddy dynamically updates the main HTML page on-the-fly when it is loaded, injecting the path or address of the family tree file specified by the container's configuration. -3. **The Genealogy Data (The Data)**: This is the family tree description file. Depending on whether you have local photo assets: - * **Unzipped File (`.ged`)**: If you do not have local photos, you can serve your standard, plain-text `.ged` file directly. - * **Zipped Archive (`.gdz` or `.zip`)**: If you want to bundle family photos, you compress the `.ged` file and the photos together into a single archive. Topola Viewer will automatically unzip and map the photos to the tree. - Serving this as a single file (either `.ged` or `.gdz`) keeps the container setup simple and highly portable. +1. **The User's Web Browser (The App)**: This is where Topola Viewer actually + runs. Since it is a single-page React application, the browser downloads the + application files once and executes all the logic, chart rendering, and user + interactions locally on the user's computer. +2. **The Caddy Web Server (The Helper)**: This is a lightweight, secure, and + fast background server running inside the Docker container. Its job is to + serve the application files (HTML, CSS, JavaScript) and the family tree + package when the browser requests them. Additionally, Caddy dynamically + updates the main HTML page on-the-fly when it is loaded, injecting the path + or address of the family tree file specified by the container's + configuration. +3. **The Genealogy Data (The Data)**: This is the family tree description file. + Depending on whether you have local photo assets: + - **Unzipped File (`.ged`)**: If you do not have local photos, you can serve + your standard, plain-text `.ged` file directly. + - **Zipped Archive (`.gdz` or `.zip`)**: If you want to bundle family photos, + you compress the `.ged` file and the photos together into a single archive. + Topola Viewer will automatically unzip and map the photos to the tree. + Serving this as a single file (either `.ged` or `.gdz`) keeps the container + setup simple and highly portable. ## 3. Alternative Designs Considered & Rejected -To ensure future development does not regress or re-argue established choices, this section documents the architectural patterns that were thoroughly evaluated but ultimately rejected. +To ensure future development does not regress or re-argue established choices, +this section documents the architectural patterns that were thoroughly evaluated +but ultimately rejected. ### Option A: Build-Time Variable Injection (`VITE_STATIC_URL`) -* **Design Proposal**: Build custom container images by setting `VITE_STATIC_URL` during the Vite production build step (`npm run build`) inside the Dockerfile. -* **Why Rejected**: - * **Heavy & Slow Builds**: Recompiling a modern React SPA inside Docker requires a full Node.js runtime, downloading `node_modules`, and compiling TypeScript. This takes minutes, consumes significant system resources, and makes building a custom image locally a highly friction-filled developer experience. - * **No Runtime Dynamism**: Since the static URL is hardcoded into minified JS assets during compiling, users cannot change their genealogy file path dynamically when running the container. A simple task like pointing the container to a new GEDCOM file would require a full image rebuild, rather than a simple environment variable or volume mount adjustment. +- **Design Proposal**: Build custom container images by setting + `VITE_STATIC_URL` during the Vite production build step (`npm run build`) + inside the Dockerfile. +- **Why Rejected**: + - **Heavy & Slow Builds**: Recompiling a modern React SPA inside Docker + requires a full Node.js runtime, downloading `node_modules`, and compiling + TypeScript. This takes minutes, consumes significant system resources, and + makes building a custom image locally a highly friction-filled developer + experience. + - **No Runtime Dynamism**: Since the static URL is hardcoded into minified JS + assets during compiling, users cannot change their genealogy file path + dynamically when running the container. A simple task like pointing the + container to a new GEDCOM file would require a full image rebuild, rather + than a simple environment variable or volume mount adjustment. ### Option B: Client-Side Config Fetching (`fetch('/config.json')`) -* **Design Proposal**: At React application startup, trigger an asynchronous HTTP request (`fetch('/config.json')`) to retrieve the configuration and target tree location. -* **Why Rejected**: - * **Unnecessary Startup Latency**: Every asynchronous network call in a client-side SPA blocks the React application mount lifecycle. Even on high-performance servers, fetching `/config.json` introduces an extra network roundtrip at boot time. +- **Design Proposal**: At React application startup, trigger an asynchronous + HTTP request (`fetch('/config.json')`) to retrieve the configuration and + target tree location. +- **Why Rejected**: + - **Unnecessary Startup Latency**: Every asynchronous network call in a + client-side SPA blocks the React application mount lifecycle. Even on + high-performance servers, fetching `/config.json` introduces an extra + network roundtrip at boot time. ### Option C: Shell-Based Template Rendering (Nginx Alpine + `envsubst`) -* **Design Proposal**: Run the static application on an Nginx Alpine base container, using a startup shell script and the `envsubst` tool to substitute environment variables inside `index.html` before launching the web server. -* **Why Rejected**: - * **Vulnerability Attack Surface**: Standard Linux and Alpine base images contain shell environments (`/bin/sh`), package managers (`apk`), and standard operating system utilities. These represent a non-zero container attack surface, leading to vulnerability alerts (CVEs) in enterprise environments. +- **Design Proposal**: Run the static application on an Nginx Alpine base + container, using a startup shell script and the `envsubst` tool to substitute + environment variables inside `index.html` before launching the web server. +- **Why Rejected**: + - **Vulnerability Attack Surface**: Standard Linux and Alpine base images + contain shell environments (`/bin/sh`), package managers (`apk`), and + standard operating system utilities. These represent a non-zero container + attack surface, leading to vulnerability alerts (CVEs) in enterprise + environments. ### Option D: Custom Compiled Go Static Server -* **Design Proposal**: Compile a custom, lightweight 35-line Go static web server program that handles SPA routing and dynamically injects environment variables directly into the `index.html` served from memory. -* **Why Rejected**: - * **Maintenance Complexity Overhead**: Although highly performant and lightweight (~10MB total container size), introducing custom compiled server source code adds to repository maintenance. Developers would have to test, audit, and maintain custom Go HTTP routing logic alongside their main React/TypeScript codebase. Using an off-the-shelf server (Caddy) eliminates this maintenance entirely. +- **Design Proposal**: Compile a custom, lightweight 35-line Go static web + server program that handles SPA routing and dynamically injects environment + variables directly into the `index.html` served from memory. +- **Why Rejected**: + - **Maintenance Complexity Overhead**: Although highly performant and + lightweight (~10MB total container size), introducing custom compiled server + source code adds to repository maintenance. Developers would have to test, + audit, and maintain custom Go HTTP routing logic alongside their main + React/TypeScript codebase. Using an off-the-shelf server (Caddy) eliminates + this maintenance entirely. ### Option E: Multi-Architecture Image Support (`linux/arm64`) -* **Design Proposal**: Package and publish multi-architecture container images targeting both standard x86_64 (`linux/amd64`) and ARM64 (`linux/arm64`) platforms. -* **Why Rejected**: - * **Pipeline Emulation Latency**: Building multi-architecture images on standard AMD64 GitHub Actions runners requires virtualized instruction emulation via QEMU. Emulating the TypeScript build and Go compiler inside a QEMU virtual environment increases container compilation cycles by up to 10x to 20x, significantly bloating release delays. - * **No Server-Side Execution Penalty**: Topola Viewer is a pure, client-side React single-page application (SPA). The browser executes all chart rendering and data logic on the end-user's computer (whether it runs on x86_64, ARM64/Apple Silicon, or mobile platforms). The container's internal web server (Caddy) simply serves static HTML/JS assets. Running the `linux/amd64` image under standard Docker architecture translation (e.g., Rosetta 2 or Docker Desktop VM) on ARM64/M-series hosts has absolutely zero visible performance penalty. - * **Workflow Stability**: Focusing exclusively on `linux/amd64` standardizes our GitHub Actions runner steps, completely removes complex third-party dependencies like `setup-qemu-action`, and ensures build cycles remain blazingly fast, secure, and reliable. +- **Design Proposal**: Package and publish multi-architecture container images + targeting both standard x86_64 (`linux/amd64`) and ARM64 (`linux/arm64`) + platforms. +- **Why Rejected**: + - **Pipeline Emulation Latency**: Building multi-architecture images on + standard AMD64 GitHub Actions runners requires virtualized instruction + emulation via QEMU. Emulating the TypeScript build and Go compiler inside a + QEMU virtual environment increases container compilation cycles by up to 10x + to 20x, significantly bloating release delays. + - **No Server-Side Execution Penalty**: Topola Viewer is a pure, client-side + React single-page application (SPA). The browser executes all chart + rendering and data logic on the end-user's computer (whether it runs on + x86_64, ARM64/Apple Silicon, or mobile platforms). The container's internal + web server (Caddy) simply serves static HTML/JS assets. Running the + `linux/amd64` image under standard Docker architecture translation (e.g., + Rosetta 2 or Docker Desktop VM) on ARM64/M-series hosts has absolutely zero + visible performance penalty. + - **Workflow Stability**: Focusing exclusively on `linux/amd64` standardizes + our GitHub Actions runner steps, completely removes complex third-party + dependencies like `setup-qemu-action`, and ensures build cycles remain + blazingly fast, secure, and reliable. ## 4. Detailed Implementation Plan -This section outlines the complete, step-by-step implementation plan. It lists every file that will be modified or created, the exact code modifications or configurations required, and the explicit engineering rationale for each. +This section outlines the complete, step-by-step implementation plan. It lists +every file that will be modified or created, the exact code modifications or +configurations required, and the explicit engineering rationale for each. ### Step 1: Add Global Config Injection Target to HTML -* **Target File**: [index.html](../index.html) (Modify) -* **Action**: Add a `` tag containing the dynamic injection placeholder inside the `` tag: +- **Target File**: [index.html](../index.html) (Modify) +- **Action**: Add a `` tag containing the dynamic injection placeholder + inside the `` tag: ```html - + ``` -* **Rationale**: - * Storing the configuration safely inside a `` tag content attribute ensures it is parsed strictly as data rather than executable code, preventing Reflected Cross-Site Scripting (XSS) or script injection vulnerabilities. - * **HTML Parsing Protection**: Enclosing the template expression in backticks (`` `STATIC_URL` ``) prevents syntactically broken nested double quotes in the HTML `content` attribute, which would otherwise break standard browser HTML tag parsing. - * The Caddy `| html` filter guarantees that the environment variable is fully HTML-entity-escaped before injection. - * The placeholder syntax `{{ env `STATIC_URL` | html }}` is evaluated dynamically by Caddy when serving the page, adding zero network latency. +- **Rationale**: + - Storing the configuration safely inside a `` tag content attribute + ensures it is parsed strictly as data rather than executable code, + preventing Reflected Cross-Site Scripting (XSS) or script injection + vulnerabilities. + - **HTML Parsing Protection**: Enclosing the template expression in backticks + (`` `STATIC_URL` ``) prevents syntactically broken nested double quotes in + the HTML `content` attribute, which would otherwise break standard browser + HTML tag parsing. + - The Caddy `| html` filter guarantees that the environment variable is fully + HTML-entity-escaped before injection. + - The placeholder syntax `{{ env `STATIC_URL` | html }}` is evaluated + dynamically by Caddy when serving the page, adding zero network latency. ### Step 2: Update Application Boot Logic to Handle Global Config -* **Target File**: [src/app.tsx](../src/app.tsx) (Modify) -* **Action**: Update the application boot logic to handle dynamic config via the `` tag or Vite static URL, override standalone/CORS properties, and adjust route settings: - * **Config Resolution**: Retrieve the statically-served GEDCOM/GDZ URL. - * **Arguments Setup**: Disable standalone mode and bypass CORS handling when a static URL is provided. - * **Conditional Routing**: Force routing directly to `/view` (bypassing the standard intro landing page) when `staticUrl` is set. + +- **Target File**: [src/app.tsx](../src/app.tsx) (Modify) +- **Action**: Update the application boot logic to handle dynamic config via the + `` tag or Vite static URL, override standalone/CORS properties, and + adjust route settings: + - **Config Resolution**: Retrieve the statically-served GEDCOM/GDZ URL. + - **Arguments Setup**: Disable standalone mode and bypass CORS handling when a + static URL is provided. + - **Conditional Routing**: Force routing directly to `/view` (bypassing the + standard intro landing page) when `staticUrl` is set. + ```typescript // 1. Global Config Resolution function getStaticUrl(): string | undefined { @@ -122,14 +207,26 @@ This section outlines the complete, step-by-step implementation plan. It lists e )} ``` -* **Rationale**: - * **Security & Safety**: Retrieving configuration via the DOM's `` element ensures we parse data context safely rather than relying on direct executable script template injection. - * **UX Modularity (Zero-Friction Mode)**: Bypassing the Intro page and disabling the standard "open file" menus (`standalone: false`) transforms the general-purpose viewer into a streamlined, dedicated instance for the preloaded tree. - * **Backward Compatibility**: Keeps standard dev runs (`npm start`) and static deployments (GitHub Pages/WikiTree) working out-of-the-box because the template string `{{ env "STATIC_URL" }}` is safely ignored when it hasn't been evaluated by Caddy. + +- **Rationale**: + - **Security & Safety**: Retrieving configuration via the DOM's `` + element ensures we parse data context safely rather than relying on direct + executable script template injection. + - **UX Modularity (Zero-Friction Mode)**: Bypassing the Intro page and + disabling the standard "open file" menus (`standalone: false`) transforms + the general-purpose viewer into a streamlined, dedicated instance for the + preloaded tree. + - **Backward Compatibility**: Keeps standard dev runs (`npm start`) and static + deployments (GitHub Pages/WikiTree) working out-of-the-box because the + template string `{{ env "STATIC_URL" }}` is safely ignored when it hasn't + been evaluated by Caddy. ### Step 3: Create Caddy Server Configuration -* **Target File**: `docker/Caddyfile` (New File in dedicated directory) -* **Action**: Add Caddy serving rules to handle robust security headers, optimized caching, SPA routing, and active template evaluation: + +- **Target File**: `docker/Caddyfile` (New File in dedicated directory) +- **Action**: Add Caddy serving rules to handle robust security headers, + optimized caching, SPA routing, and active template evaluation: + ```caddy { # Disable administrative API to prevent permission errors in read-only environments @@ -138,10 +235,10 @@ This section outlines the complete, step-by-step implementation plan. It lists e :8080 { root * /app/public - + # Compress static assets (HTML, JS, CSS, GEDCOM text files) encode gzip zstd - + file_server # Robust Security Headers @@ -173,19 +270,36 @@ This section outlines the complete, step-by-step implementation plan. It lists e try_files {path} /index.html } ``` -* **Rationale**: - * **Security Headers**: Protects production deployments against clickjacking, MIME-sniffing, and referrer leaks through standard secure response headers. - * **Disable Admin Endpoint**: Setting `admin off` prevents Caddy from attempting to bind administrative sockets or write auto-saved configuration files (`caddy_autosave.json`) to its working directory, avoiding fatal startup failures in rootless containers and secure read-only filesystems. - * **Asset Compression**: Adding `encode gzip zstd` ensures that large client-side JS bundles and plain-text GEDCOM files are compressed, dramatically reducing load-time latency and server bandwidth costs. - * **Precise Caching Rules**: Restructures caching to only apply `immutable` tags to assets in `/assets/*` (which contains Vite's hashed JS/CSS files). This ensures user-supplied dynamic family trees (`.ged`, `.gdz`) and mounted photos do not get cached permanently, allowing instant runtime updates. - * **HTML Templating**: Caddy dynamically processes variables (like `{{ env `STATIC_URL` | html }}`) for HTML documents, avoiding performance overhead on other resources. - * **Non-Root Port**: Serving from port `8080` permits the container to run as an unprivileged user without root capabilities. + +- **Rationale**: + - **Security Headers**: Protects production deployments against clickjacking, + MIME-sniffing, and referrer leaks through standard secure response headers. + - **Disable Admin Endpoint**: Setting `admin off` prevents Caddy from + attempting to bind administrative sockets or write auto-saved configuration + files (`caddy_autosave.json`) to its working directory, avoiding fatal + startup failures in rootless containers and secure read-only filesystems. + - **Asset Compression**: Adding `encode gzip zstd` ensures that large + client-side JS bundles and plain-text GEDCOM files are compressed, + dramatically reducing load-time latency and server bandwidth costs. + - **Precise Caching Rules**: Restructures caching to only apply `immutable` + tags to assets in `/assets/*` (which contains Vite's hashed JS/CSS files). + This ensures user-supplied dynamic family trees (`.ged`, `.gdz`) and mounted + photos do not get cached permanently, allowing instant runtime updates. + - **HTML Templating**: Caddy dynamically processes variables (like + `{{ env `STATIC_URL` | html }}`) for HTML documents, avoiding performance + overhead on other resources. + - **Non-Root Port**: Serving from port `8080` permits the container to run as + an unprivileged user without root capabilities. --- ### Step 4: Create Multi-Stage Dockerfile -* **Target File**: `docker/Dockerfile` (New File in dedicated directory) -* **Action**: Write the multi-stage compilation and assembly pipeline using a pre-compiled Caddy binary and Google's library-free Distroless Static base image running as nonroot: + +- **Target File**: `docker/Dockerfile` (New File in dedicated directory) +- **Action**: Write the multi-stage compilation and assembly pipeline using a + pre-compiled Caddy binary and Google's library-free Distroless Static base + image running as nonroot: + ```dockerfile # Stage 1: Compile the React/TypeScript bundle FROM node:20-alpine AS react-builder @@ -212,16 +326,34 @@ This section outlines the complete, step-by-step implementation plan. It lists e EXPOSE 8080 ENTRYPOINT ["/usr/bin/caddy", "run", "--config", "./Caddyfile", "--adapter", "caddyfile"] ``` -* **Rationale**: - * **Clean Compilation Separation**: Restructures compilation using a multi-stage build where the heavy Node.js and TypeScript compiler packages are restricted entirely to the builder stage, keeping the final runner image extremely lightweight and free of development tools. - * **Static Caddy Binary**: Copying the pre-compiled, statically linked Caddy binary directly from the official `caddy` Alpine image. Since the official Caddy binary is a pure, library-independent Go executable built without CGO, it runs flawlessly on top of a Google Distroless Static image, completely bypassing the need to compile Caddy from source and reducing build times by several minutes. - * **Distroless Static Base**: Standardizing on `distroless/static-debian12` removes *all* dynamic libraries, shells, and packages from the runtime container. This cuts container image size to under ~25MB and eliminates runtime vulnerability scan alerts (CVEs) completely. - * **Non-Root Execution**: Switching to `USER nonroot:nonroot` inside the production stage ensures the application runs with minimum privileges, satisfying strict enterprise and Kubernetes execution policies. - * **Explicit File Ownership**: Using `COPY --chown=nonroot:nonroot` guarantees that all files in the runtime container are owned by the runtime non-root user, bypassing permissions conflicts. + +- **Rationale**: + - **Clean Compilation Separation**: Restructures compilation using a + multi-stage build where the heavy Node.js and TypeScript compiler packages + are restricted entirely to the builder stage, keeping the final runner image + extremely lightweight and free of development tools. + - **Static Caddy Binary**: Copying the pre-compiled, statically linked Caddy + binary directly from the official `caddy` Alpine image. Since the official + Caddy binary is a pure, library-independent Go executable built without CGO, + it runs flawlessly on top of a Google Distroless Static image, completely + bypassing the need to compile Caddy from source and reducing build times by + several minutes. + - **Distroless Static Base**: Standardizing on `distroless/static-debian12` + removes _all_ dynamic libraries, shells, and packages from the runtime + container. This cuts container image size to under ~25MB and eliminates + runtime vulnerability scan alerts (CVEs) completely. + - **Non-Root Execution**: Switching to `USER nonroot:nonroot` inside the + production stage ensures the application runs with minimum privileges, + satisfying strict enterprise and Kubernetes execution policies. + - **Explicit File Ownership**: Using `COPY --chown=nonroot:nonroot` guarantees + that all files in the runtime container are owned by the runtime non-root + user, bypassing permissions conflicts. ### Step 5: Create Docker Ignore File -* **Target File**: `.dockerignore` (New File at root) -* **Action**: Exclude local development folders and build environments from entering the Docker context: + +- **Target File**: `.dockerignore` (New File at root) +- **Action**: Exclude local development folders and build environments from + entering the Docker context: ```text node_modules dist @@ -231,12 +363,17 @@ This section outlines the complete, step-by-step implementation plan. It lists e .vscode README.md ``` -* **Rationale**: - * Speeds up local `docker build` times by preventing megabytes of local folders (like `node_modules` and local `dist` directories) from uploading to the Docker daemon build context. +- **Rationale**: + - Speeds up local `docker build` times by preventing megabytes of local + folders (like `node_modules` and local `dist` directories) from uploading to + the Docker daemon build context. ### Step 6: Create GitHub Actions Container Deployment Workflow -* **Target File**: `.github/workflows/deploy-docker.yml` (New File) -* **Action**: Setup a GitHub Actions workflow to compile, tag, and publish the container images to GHCR, handling lowercase names and safe tagging: + +- **Target File**: `.github/workflows/deploy-docker.yml` (New File) +- **Action**: Setup a GitHub Actions workflow to compile, tag, and publish the + container images to GHCR, handling lowercase names and safe tagging: + ```yaml name: Build and Publish Docker Image @@ -252,55 +389,71 @@ This section outlines the complete, step-by-step implementation plan. It lists e packages: write steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - - name: Convert Repository Name to Lowercase - run: | - echo "GHCR_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Convert Repository Name to Lowercase + run: | + echo "GHCR_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.GHCR_IMAGE_NAME }} - tags: | - type=sha - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.GHCR_IMAGE_NAME }} + tags: | + type=sha + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max ``` -* **Rationale**: - * **Strict Lowercase Registry Names**: Registry image names must be strictly lowercase. Converting the repository path to lowercase prevents Docker push failures due to uppercase organization or repository names (e.g. `PeWu`). - * **Safe Production Tagging**: Restricts pushing the `latest` tag to master branch runs only, preventing development branches or forks from accidentally overwriting the stable master production image. - * **No QEMU Dependency**: Because the image targets standard `linux/amd64` servers directly, we bypass slow CPU instruction emulation completely. This eliminates the `setup-qemu-action` dependency, protecting the workflow against virtualizer crashes and speeding up build initialization. - * **Git SHA Tagging**: Generates git SHA tags automatically, allowing precise auditing and rolling back of deployments. - * **Modern Actions Standard**: Upgrades all critical deployment actions to their latest major releases to optimize runner speeds, secure security improvements, and align with Node 20/22 GitHub Action standard runner specifications. - * **Active GitHub Caching**: Leverages standard action build cache stores to reuse layers and accelerate build cycles. + +- **Rationale**: + - **Strict Lowercase Registry Names**: Registry image names must be strictly + lowercase. Converting the repository path to lowercase prevents Docker push + failures due to uppercase organization or repository names (e.g. `PeWu`). + - **Safe Production Tagging**: Restricts pushing the `latest` tag to master + branch runs only, preventing development branches or forks from accidentally + overwriting the stable master production image. + - **No QEMU Dependency**: Because the image targets standard `linux/amd64` + servers directly, we bypass slow CPU instruction emulation completely. This + eliminates the `setup-qemu-action` dependency, protecting the workflow + against virtualizer crashes and speeding up build initialization. + - **Git SHA Tagging**: Generates git SHA tags automatically, allowing precise + auditing and rolling back of deployments. + - **Modern Actions Standard**: Upgrades all critical deployment actions to + their latest major releases to optimize runner speeds, secure security + improvements, and align with Node 20/22 GitHub Action standard runner + specifications. + - **Active GitHub Caching**: Leverages standard action build cache stores to + reuse layers and accelerate build cycles. ### Step 7: Couple Docker Publication to existing Main Deployment Pipeline -* **Target File**: [.github/workflows/deploy-everywhere.yml](deploy-everywhere.yml) (Modify) -* **Action**: Integrate the newly created Docker workflow as a concurrent job: + +- **Target File**: + [.github/workflows/deploy-everywhere.yml](deploy-everywhere.yml) (Modify) +- **Action**: Integrate the newly created Docker workflow as a concurrent job: + ```yaml name: Deploy everywhere @@ -318,130 +471,201 @@ This section outlines the complete, step-by-step implementation plan. It lists e uses: ./.github/workflows/deploy-docker.yml secrets: inherit ``` -* **Rationale**: - * Integrates the container deployment pipeline seamlessly into the main release trigger (`deploy-everywhere`), ensuring that Docker, GH Pages, and WikiTree versions are always updated in lockstep. + +- **Rationale**: + - Integrates the container deployment pipeline seamlessly into the main + release trigger (`deploy-everywhere`), ensuring that Docker, GH Pages, and + WikiTree versions are always updated in lockstep. ### Step 8: Document Container Usage in Main README -* **Target File**: `README.md` (Modify) -* **Action**: Add a dedicated "Docker Container Deployment" section with clear run, build, mount, and standalone template instructions: - ```markdown + +- **Target File**: `README.md` (Modify) +- **Action**: Add a dedicated "Docker Container Deployment" section with clear + run, build, mount, and standalone template instructions: + + ````markdown ## Docker Container Deployment - Topola Viewer can be run locally or deployed to standard cloud environments using Docker. + Topola Viewer can be run locally or deployed to standard cloud environments + using Docker. ### Running Topola Viewer + To pull and run Topola Viewer: + ```bash docker run -d -p 8080:8080 ghcr.io/pewu/topola-viewer:latest ``` - Open your web browser and go to `http://localhost:8080` to upload your family tree files locally. + ```` + + Open your web browser and go to `http://localhost:8080` to upload your family + tree files locally. ### Running with Your Own Data (Zero-Build Run) - You can serve a standalone, pre-loaded family tree with zero compilation by mounting your family tree data (a `.ged` file or a zipped `.gdz` archive containing photos) directly into the running container: + + You can serve a standalone, pre-loaded family tree with zero compilation by + mounting your family tree data (a `.ged` file or a zipped `.gdz` archive + containing photos) directly into the running container: + ```bash docker run -d -p 8080:8080 \ -e STATIC_URL=my_family.gdz \ -v ./my_family.gdz:/app/public/my_family.gdz \ ghcr.io/pewu/topola-viewer:latest ``` - + ### Building the Base Image Locally + To build the base image from source: + ```bash docker build -t topola-viewer -f docker/Dockerfile . ``` ### Ready-To-Use Standalone Templates - For creating completely self-contained Docker images that bundle your genealogy data and serve it instantly, see these pre-configured examples: - 1. **[Simple Standalone Tree](docker/examples/simple/)**: Demonstrates how to package and pre-load a `.ged` file directly inside a custom image. - 2. **[Standalone Tree with Photos](docker/examples/photos/)**: Packages your family tree and a `photos/` folder into a valid `.gdz` archive on-the-fly. + For creating completely self-contained Docker images that bundle your + genealogy data and serve it instantly, see these pre-configured examples: + 1. **[Simple Standalone Tree](docker/examples/simple/)**: Demonstrates how to + package and pre-load a `.ged` file directly inside a custom image. + 2. **[Standalone Tree with Photos](docker/examples/photos/)**: Packages your + family tree and a `photos/` folder into a valid `.gdz` archive on-the-fly. + ``` -* **Rationale**: - * Ensures the new Docker feature has first-class visibility, clear instructions, and easy references to packaged standalone templates for end users, enabling both basic runs, volume-mounted local data serving, and built-in custom imagery. + + ``` + +- **Rationale**: + - Ensures the new Docker feature has first-class visibility, clear + instructions, and easy references to packaged standalone templates for end + users, enabling both basic runs, volume-mounted local data serving, and + built-in custom imagery. ### Step 9: Provide Custom Image Templates (Simple & Zipped on the Fly) -* **Target Files**: - * `docker/examples/simple/Dockerfile` (New File) - * `docker/examples/simple/README.md` (New File) - * `docker/examples/simple/family.ged` (New simple, valid example GEDCOM file) - * `docker/examples/photos/Dockerfile` (New File) - * `docker/examples/photos/README.md` (New File) - * `docker/examples/photos/family.ged` (New simple, valid example GEDCOM file) - * `docker/examples/photos/photos/I1.jpg` (New simple, valid example photo asset) - * `docker/examples/photos/photos/I2.jpg` (New simple, valid example photo asset) -* **Action**: Create two dedicated subdirectories containing turnkey templates. To make these examples instantly runnable out-of-the-box, we provide simple, valid example files (`family.ged`, `I1.jpg`, and `I2.jpg`) inside the repository so users can immediately run `docker build` and test the container features without having to prepare their own private files first. + +- **Target Files**: + - `docker/examples/simple/Dockerfile` (New File) + - `docker/examples/simple/README.md` (New File) + - `docker/examples/simple/family.ged` (New simple, valid example GEDCOM file) + - `docker/examples/photos/Dockerfile` (New File) + - `docker/examples/photos/README.md` (New File) + - `docker/examples/photos/family.ged` (New simple, valid example GEDCOM file) + - `docker/examples/photos/photos/I1.jpg` (New simple, valid example photo + asset) + - `docker/examples/photos/photos/I2.jpg` (New simple, valid example photo + asset) +- **Action**: Create two dedicated subdirectories containing turnkey templates. + To make these examples instantly runnable out-of-the-box, we provide simple, + valid example files (`family.ged`, `I1.jpg`, and `I2.jpg`) inside the + repository so users can immediately run `docker build` and test the container + features without having to prepare their own private files first. #### 1. Simple GEDCOM Template (`docker/examples/simple`) - * **`docker/examples/simple/Dockerfile`**: - ```dockerfile - # Start from the official compiled container - FROM ghcr.io/pewu/topola-viewer:latest - # Copy the unzipped GEDCOM file directly into public folder - COPY family.ged /app/public/family.ged +- **`docker/examples/simple/Dockerfile`**: - # Configure server to pre-load this raw GEDCOM file - ENV STATIC_URL=family.ged - ``` - * **`docker/examples/simple/README.md`**: - ```markdown - # Standalone GEDCOM Container Example + ```dockerfile + # Start from the official compiled container + FROM ghcr.io/pewu/topola-viewer:latest - This example builds a self-contained image that hosts a single `.ged` file directly (no photos). + # Copy the unzipped GEDCOM file directly into public folder + COPY family.ged /app/public/family.ged - ## Instructions + # Configure server to pre-load this raw GEDCOM file + ENV STATIC_URL=family.ged + ``` - 1. Put your GEDCOM file in this directory and name it `family.ged`. - 2. Build your custom container: - ```bash - docker build -t my-simple-tree . - ``` - 3. Run your container: - ```bash - docker run -d -p 8080:8080 my-simple-tree - ``` - ``` +- **`docker/examples/simple/README.md`**: + + ````markdown + # Standalone GEDCOM Container Example + + This example builds a self-contained image that hosts a single `.ged` file + directly (no photos). + + ## Instructions + + 1. Put your GEDCOM file in this directory and name it `family.ged`. + 2. Build your custom container: + ```bash + docker build -t my-simple-tree . + ``` + ```` + + 3. Run your container: + ```bash + docker run -d -p 8080:8080 my-simple-tree + ``` + + ``` + + ``` #### 2. Zipped Media Template (`docker/examples/photos`) - * **`docker/examples/photos/Dockerfile`**: - ```dockerfile - # Stage 1: Multi-stage helper to zip GEDCOM & photos together preserving directory structure - FROM alpine:latest AS zipper - RUN apk add --no-cache zip - WORKDIR /build - COPY family.ged ./ - COPY photos/ ./photos/ - # Zip contents relative to build root to preserve directories as referenced in GEDCOM - RUN zip -r family.gdz family.ged photos/ - # Stage 2: Load the zip file into the official container - FROM ghcr.io/pewu/topola-viewer:latest - COPY --from=zipper /build/family.gdz /app/public/family.gdz - ENV STATIC_URL=family.gdz - ``` - * **`docker/examples/photos/README.md`**: - ```markdown - # Standalone Zipped Family Tree Container with Photos +- **`docker/examples/photos/Dockerfile`**: - This example leverages a multi-stage Docker build to automatically compress your `.ged` file and `photos/` folder into a secure `.gdz` archive on-the-fly, preserving your image directory path structures. + ```dockerfile + # Stage 1: Multi-stage helper to zip GEDCOM & photos together preserving directory structure + FROM alpine:latest AS zipper + RUN apk add --no-cache zip + WORKDIR /build + COPY family.ged ./ + COPY photos/ ./photos/ + # Zip contents relative to build root to preserve directories as referenced in GEDCOM + RUN zip -r family.gdz family.ged photos/ - ## Structure - * Place your `family.ged` file here. - * Place your photos also in this directory, or inside a `photos/` folder in this directory. If you put the photos in the `photos/` directory, make sure your GEDCOM file contains file references containing the `photos/` prefix. See the sample [family.ged](family.ged). + # Stage 2: Load the zip file into the official container + FROM ghcr.io/pewu/topola-viewer:latest + COPY --from=zipper /build/family.gdz /app/public/family.gdz + ENV STATIC_URL=family.gdz + ``` - ## Instructions +- **`docker/examples/photos/README.md`**: - 1. Build your custom container: - ```bash - docker build -t my-photo-tree . - ``` - 2. Run your container: - ```bash - docker run -d -p 8080:8080 my-photo-tree - ``` - ``` -* **Rationale**: - * **Simple Template**: Demonstrates the standard, zero-friction path for users who just have a raw `.ged` file. - * **Photos Template (On-The-Fly Zipper)**: Solves the problem of executing commands in a shell-less, commandless distroless container. By spinning up a lightweight Alpine zipper image to execute the native `zip` tool, and copying *only* the finished `.gdz` artifact into the final stage, we achieve a completely self-contained, secure target image. - * **Preserved Path Zip File Structure**: Packaging photos by zipping from the build root (`zip -r family.gdz family.ged photos/`) preserves the exact folder hierarchy relative to the GEDCOM. This guarantees that any complex or structured media folders (e.g., `photos/1990s/wedding.jpg`) match the exact file references declared inside the GEDCOM file, avoiding broken images from flattened zip scopes. + ````markdown + # Standalone Zipped Family Tree Container with Photos + + This example leverages a multi-stage Docker build to automatically compress + your `.ged` file and `photos/` folder into a secure `.gdz` archive on-the-fly, + preserving your image directory path structures. + + ## Structure + + - Place your `family.ged` file here. + - Place your photos also in this directory, or inside a `photos/` folder in + this directory. If you put the photos in the `photos/` directory, make sure + your GEDCOM file contains file references containing the `photos/` prefix. + See the sample [family.ged](family.ged). + + ## Instructions + + 1. Build your custom container: + ```bash + docker build -t my-photo-tree . + ``` + ```` + + 2. Run your container: + ```bash + docker run -d -p 8080:8080 my-photo-tree + ``` + + ``` + + ``` + +- **Rationale**: + - **Simple Template**: Demonstrates the standard, zero-friction path for users + who just have a raw `.ged` file. + - **Photos Template (On-The-Fly Zipper)**: Solves the problem of executing + commands in a shell-less, commandless distroless container. By spinning up a + lightweight Alpine zipper image to execute the native `zip` tool, and + copying _only_ the finished `.gdz` artifact into the final stage, we achieve + a completely self-contained, secure target image. + - **Preserved Path Zip File Structure**: Packaging photos by zipping from the + build root (`zip -r family.gdz family.ged photos/`) preserves the exact + folder hierarchy relative to the GEDCOM. This guarantees that any complex or + structured media folders (e.g., `photos/1990s/wedding.jpg`) match the exact + file references declared inside the GEDCOM file, avoiding broken images from + flattened zip scopes. diff --git a/docs/IMMEDIATE_FAMILY_SECTION_DESIGN.md b/docs/IMMEDIATE_FAMILY_SECTION_DESIGN.md index b4ebd17..c78c8cb 100644 --- a/docs/IMMEDIATE_FAMILY_SECTION_DESIGN.md +++ b/docs/IMMEDIATE_FAMILY_SECTION_DESIGN.md @@ -2,86 +2,224 @@ ## Problem -Topola Viewer renders an interactive graphical family tree centered on a selected individual, displaying connected nodes for ancestors, descendants, and spouses. However, as family structures expand or users zoom in for detail, directly related family members frequently flow off the visible screen boundaries, requiring tedious canvas manipulation to locate and select them. To improve navigation efficiency and provide clear relational context at a glance, the application needs a dedicated layout block in the side panel that consolidates and displays the focused person's parents, spouses, and children. Providing these primary relationships as direct clickable links ensures users can rapidly transition focus between immediate family members regardless of their current viewport position on the graphical canvas. +Topola Viewer renders an interactive graphical family tree centered on a +selected individual, displaying connected nodes for ancestors, descendants, and +spouses. However, as family structures expand or users zoom in for detail, +directly related family members frequently flow off the visible screen +boundaries, requiring tedious canvas manipulation to locate and select them. To +improve navigation efficiency and provide clear relational context at a glance, +the application needs a dedicated layout block in the side panel that +consolidates and displays the focused person's parents, spouses, and children. +Providing these primary relationships as direct clickable links ensures users +can rapidly transition focus between immediate family members regardless of +their current viewport position on the graphical canvas. ## Technical Plan -To introduce the immediate family view without disrupting existing features, the implementation introduces a focused visual component that acts as an intermediary between the raw genealogy data and the user interface. +To introduce the immediate family view without disrupting existing features, the +implementation introduces a focused visual component that acts as an +intermediary between the raw genealogy data and the user interface. The system relies on three primary components working together: -1. **Genealogy Data Store**: The core repository containing all parsed individuals and family records. It provides lookup utilities to resolve cross-references between children and their parents or spouses. -2. **Immediate Family Component**: A dedicated new interface element embedded directly inside the side panel. It queries the data store for the currently selected person, extracts their primary set of parents, and groups all associated children under their respective spouses or partners. -3. **Navigation Module**: When a user clicks on any parent, spouse, or child link within the new block, this module updates the application's active web address with the chosen relative's identifier. This action instantly re-centers both the side panel and the primary visual tree onto the newly selected family member. + +1. **Genealogy Data Store**: The core repository containing all parsed + individuals and family records. It provides lookup utilities to resolve + cross-references between children and their parents or spouses. +2. **Immediate Family Component**: A dedicated new interface element embedded + directly inside the side panel. It queries the data store for the currently + selected person, extracts their primary set of parents, and groups all + associated children under their respective spouses or partners. +3. **Navigation Module**: When a user clicks on any parent, spouse, or child + link within the new block, this module updates the application's active web + address with the chosen relative's identifier. This action instantly + re-centers both the side panel and the primary visual tree onto the newly + selected family member. ## Alternatives -During the initial design discussions, several alternative implementation strategies were evaluated and ultimately ruled out to ensure optimal user experience and maintainability: +During the initial design discussions, several alternative implementation +strategies were evaluated and ultimately ruled out to ensure optimal user +experience and maintainability: ### 1. Integrating Relationships into the Event Timeline -* **Concept**: Interweave parents and children directly into the chronologically sorted lifecycle event list (e.g., rendering children as "Child Born" timeline events and placing parents near the target person's birth event). -* **Reason for Rejection**: Structural relationships are fundamentally distinct from point-in-time events. Forcing them into an event-driven layout scatters family members across the full vertical height of the panel, creating severe timeline clutter and undermining the core goal of rapid navigation. + +- **Concept**: Interweave parents and children directly into the chronologically + sorted lifecycle event list (e.g., rendering children as "Child Born" timeline + events and placing parents near the target person's birth event). +- **Reason for Rejection**: Structural relationships are fundamentally distinct + from point-in-time events. Forcing them into an event-driven layout scatters + family members across the full vertical height of the panel, creating severe + timeline clutter and undermining the core goal of rapid navigation. ### 2. Removing the Timeline Spouse Link -* **Concept**: Strip out the standalone spouse navigation link from the chronological marriage event entries to prevent duplicating the link in both the timeline and the new Immediate Family block. -* **Reason for Rejection**: Detaching the spouse link from the timeline breaks historical context, separating the marriage ceremony record from the actual partner involved. Maintaining both preserves event completeness while establishing a centralized navigation block. + +- **Concept**: Strip out the standalone spouse navigation link from the + chronological marriage event entries to prevent duplicating the link in both + the timeline and the new Immediate Family block. +- **Reason for Rejection**: Detaching the spouse link from the timeline breaks + historical context, separating the marriage ceremony record from the actual + partner involved. Maintaining both preserves event completeness while + establishing a centralized navigation block. ### 3. Rendering a Flat List of Children -* **Concept**: Consolidate all children into a single flat list ordered strictly by birth date, omitting spousal boundaries. -* **Reason for Rejection**: Fails to represent blended families clearly. Explicitly grouping children under their respective spouse or partner headers makes full-sibling versus half-sibling structures immediately obvious to the user. + +- **Concept**: Consolidate all children into a single flat list ordered strictly + by birth date, omitting spousal boundaries. +- **Reason for Rejection**: Fails to represent blended families clearly. + Explicitly grouping children under their respective spouse or partner headers + makes full-sibling versus half-sibling structures immediately obvious to the + user. ### 4. Uniformly Showing or Hiding Unknown Spouses -* **Concept**: Apply a blanket rule to either always display an "Unknown Spouse" header for single-parent records or entirely hide unknown headers across all scenarios. -* **Reason for Rejection**: Always showing the header adds unnecessary visual noise to standard single-family layouts. Always hiding it obscures complex relationship boundaries when an individual has children across multiple partners where some partner names are unrecorded. The conditional approach dynamically cleans up single-family displays while preserving vital structural boundaries for multi-partner families. + +- **Concept**: Apply a blanket rule to either always display an "Unknown Spouse" + header for single-parent records or entirely hide unknown headers across all + scenarios. +- **Reason for Rejection**: Always showing the header adds unnecessary visual + noise to standard single-family layouts. Always hiding it obscures complex + relationship boundaries when an individual has children across multiple + partners where some partner names are unrecorded. The conditional approach + dynamically cleans up single-family displays while preserving vital structural + boundaries for multi-partner families. ### 5. Supporting Multiple Sets of Parents Immediately -* **Concept**: Query all associated `FAMC` records to display biological, adoptive, and foster parent sets side-by-side from day one. -* **Reason for Rejection**: Introduces substantial UI complexity for labeling pedigree types (`PEDI` sub-tags) and requires wider structural refactoring of active focus state handling across the core canvas layout. Deferring to a single primary parent set keeps the initial feature scope robust and achievable. + +- **Concept**: Query all associated `FAMC` records to display biological, + adoptive, and foster parent sets side-by-side from day one. +- **Reason for Rejection**: Introduces substantial UI complexity for labeling + pedigree types (`PEDI` sub-tags) and requires wider structural refactoring of + active focus state handling across the core canvas layout. Deferring to a + single primary parent set keeps the initial feature scope robust and + achievable. ## Detailed Implementation -Executing this feature requires clean extensions across the side panel architecture. To maintain high code quality and ensure strict boundaries, the implementation avoids modifying shared core state or event logic directly, instead isolating rendering responsibilities into modular layers. +Executing this feature requires clean extensions across the side panel +architecture. To maintain high code quality and ensure strict boundaries, the +implementation avoids modifying shared core state or event logic directly, +instead isolating rendering responsibilities into modular layers. -Below is the exhaustive file-by-file breakdown enumerating every file that will be created or modified, accompanied by detailed implementation step guidelines and specific technical rationale. +Below is the exhaustive file-by-file breakdown enumerating every file that will +be created or modified, accompanied by detailed implementation step guidelines +and specific technical rationale. ### 1. Component Creation + #### [NEW] [immediate-family.tsx](../src/sidepanel/details/immediate-family.tsx) -* **Rationale**: Encapsulating the new feature inside a dedicated component file separates concerns, avoids bloating the orchestration component ([details.tsx](../src/sidepanel/details/details.tsx)), and centralizes specialized rendering logic for single parent sets, childless spouses, conditional unknown headers, and chronologically sorted child groupings. -* **Implementation Steps**: - 1. **Imports**: Import React elements, Semantic UI layout wrappers (`Item`, `Header`), navigation routing dependencies (`Link`, `useLocation` from `react-router`, `query-string`), localization wrappers (`FormattedMessage`, `useIntl` from `react-intl`), and GEDCOM utility functions from `../../util/gedcom_util` ([dereference](../src/util/gedcom_util.ts#L171-L183), [getName](../src/util/gedcom_util.ts#L292-L302), [pointerToId](../src/util/gedcom_util.ts#L42-L44), [resolveDate](../src/util/gedcom_util.ts#L339-L342)). - 2. **Relative Link Helper**: Define an internal component (e.g., `RelativeLink`) that renders an individual relative profile as a ``. It extracts the target relative's plain ID directly from the raw sub-entry string via `pointerToId(subEntry.data)` before dereferencing to prevent runtime crashes on broken references. It parses `location.search` using `useLocation()`, sets the `indi` query parameter to the target ID, stringifies the search parameters, routes to `/view`, and outputs the resolved display name via `getName` (falling back to localized unknown string if undefined). - 3. **Parents Block Renderer**: Implement a rendering method that extracts the active individual's tree array. Find the first sub-entry where `tag === 'FAMC'`. Guard against `undefined` if parents are unrecorded. Safely dereference this pointer against the repository's family mapping array to locate the parental family record. Scan the resolved family entry for all `HUSB` and `WIFE` sub-entries. Extract plain IDs via `pointerToId(subEntry.data)` and dereference each against the repository's individual mapping array to resolve profile records. Conditionally output localized sub-headers (e.g., "Father", "Mother") accompanied by clickable `RelativeLink` instances only for present parent records. - 4. **Spouses and Children Block Renderer**: Implement a rendering method that iterates over all `FAMS` tags within the active individual's tree. Dereference each entry to fetch its corresponding family record. For each family record, extract the partner/spouse pointers (`HUSB` or `WIFE` tags ensuring `!subEntry.data.includes(props.indi)` to prevent extracting the focused person as their own spouse) and extract all child records (`CHIL`). - 5. **Child Record Dereferencing and Sorting**: Explicitly dereference each extracted `CHIL` sub-entry against `gedcom.indis` to access child profile records. Extract their birth dates using `resolveDate` and sort the children array chronologically by birth date before outputting to ensure correct relational flow. - 6. **Conditional Unknown Spouse Logic**: Prior to outputting headers, evaluate the total length of the mapped `FAMS` array. Guard against incomplete/empty family entries by suppressing groups containing neither a valid spouse pointer nor children. If a spouse profile pointer is absent and total `FAMS` equals `1`, suppress the spouse header output. If a spouse pointer is absent and total valid `FAMS` groups exceed `1`, output a visible "Spouse: Unknown" block header to clearly define half-sibling boundaries. - 7. **Sequential Group Output**: Output each valid spousal family group as an encapsulated sub-block. Render childless spouses as complete standalone items. Output the resolved, sorted children collection as relative links below their respective spouse/partner header. - 8. **Container Wrapper**: Export the primary `ImmediateFamily` component wrapper. If active parent, spouse, or child nodes exist, wrap the consolidated layout blocks inside a single Semantic UI `` container structured with ``. If no immediate family members exist, return `null` to prevent empty dividers in the side panel DOM. + +- **Rationale**: Encapsulating the new feature inside a dedicated component file + separates concerns, avoids bloating the orchestration component + ([details.tsx](../src/sidepanel/details/details.tsx)), and centralizes + specialized rendering logic for single parent sets, childless spouses, + conditional unknown headers, and chronologically sorted child groupings. +- **Implementation Steps**: + 1. **Imports**: Import React elements, Semantic UI layout wrappers (`Item`, + `Header`), navigation routing dependencies (`Link`, `useLocation` from + `react-router`, `query-string`), localization wrappers (`FormattedMessage`, + `useIntl` from `react-intl`), and GEDCOM utility functions from + `../../util/gedcom_util` + ([dereference](../src/util/gedcom_util.ts#L171-L183), + [getName](../src/util/gedcom_util.ts#L292-L302), + [pointerToId](../src/util/gedcom_util.ts#L42-L44), + [resolveDate](../src/util/gedcom_util.ts#L339-L342)). + 2. **Relative Link Helper**: Define an internal component (e.g., + `RelativeLink`) that renders an individual relative profile as a ``. + It extracts the target relative's plain ID directly from the raw sub-entry + string via `pointerToId(subEntry.data)` before dereferencing to prevent + runtime crashes on broken references. It parses `location.search` using + `useLocation()`, sets the `indi` query parameter to the target ID, + stringifies the search parameters, routes to `/view`, and outputs the + resolved display name via `getName` (falling back to localized unknown + string if undefined). + 3. **Parents Block Renderer**: Implement a rendering method that extracts the + active individual's tree array. Find the first sub-entry where + `tag === 'FAMC'`. Guard against `undefined` if parents are unrecorded. + Safely dereference this pointer against the repository's family mapping + array to locate the parental family record. Scan the resolved family entry + for all `HUSB` and `WIFE` sub-entries. Extract plain IDs via + `pointerToId(subEntry.data)` and dereference each against the repository's + individual mapping array to resolve profile records. Conditionally output + localized sub-headers (e.g., "Father", "Mother") accompanied by clickable + `RelativeLink` instances only for present parent records. + 4. **Spouses and Children Block Renderer**: Implement a rendering method that + iterates over all `FAMS` tags within the active individual's tree. + Dereference each entry to fetch its corresponding family record. For each + family record, extract the partner/spouse pointers (`HUSB` or `WIFE` tags + ensuring `!subEntry.data.includes(props.indi)` to prevent extracting the + focused person as their own spouse) and extract all child records (`CHIL`). + 5. **Child Record Dereferencing and Sorting**: Explicitly dereference each + extracted `CHIL` sub-entry against `gedcom.indis` to access child profile + records. Extract their birth dates using `resolveDate` and sort the + children array chronologically by birth date before outputting to ensure + correct relational flow. + 6. **Conditional Unknown Spouse Logic**: Prior to outputting headers, evaluate + the total length of the mapped `FAMS` array. Guard against incomplete/empty + family entries by suppressing groups containing neither a valid spouse + pointer nor children. If a spouse profile pointer is absent and total + `FAMS` equals `1`, suppress the spouse header output. If a spouse pointer + is absent and total valid `FAMS` groups exceed `1`, output a visible + "Spouse: Unknown" block header to clearly define half-sibling boundaries. + 7. **Sequential Group Output**: Output each valid spousal family group as an + encapsulated sub-block. Render childless spouses as complete standalone + items. Output the resolved, sorted children collection as relative links + below their respective spouse/partner header. + 8. **Container Wrapper**: Export the primary `ImmediateFamily` component + wrapper. If active parent, spouse, or child nodes exist, wrap the + consolidated layout blocks inside a single Semantic UI `` container + structured with ``. If no immediate family members exist, + return `null` to prevent empty dividers in the side panel DOM. ### 2. Orchestration Layer Modification + #### [MODIFY] [details.tsx](../src/sidepanel/details/details.tsx) -* **Rationale**: Acts as the root layout orchestrator for the side panel info tab. Needs to import and render the new view block near the top of the component flow to guarantee primary relational links are visible above the fold without requiring users to scroll past massive event timelines. -* **Implementation Steps**: + +- **Rationale**: Acts as the root layout orchestrator for the side panel info + tab. Needs to import and render the new view block near the top of the + component flow to guarantee primary relational links are visible above the + fold without requiring users to scroll past massive event timelines. +- **Implementation Steps**: 1. Import the `ImmediateFamily` component from `./immediate-family`. - 2. Locate the main [Details](../src/sidepanel/details/details.tsx#L338-L387) component export function. - 3. Identify the execution block rendering `{getSectionForEachMatchingEntry(entries, props.gedcom, ['OBJE'], imageDetails)}` (line 350-355). - 4. Directly below this block, and strictly *before* the `` timeline rendering execution line, embed `` directly within the `` flow. Rely on the component's internal conditional logic to render the outer item bounds. + 2. Locate the main [Details](../src/sidepanel/details/details.tsx#L338-L387) + component export function. + 3. Identify the execution block rendering + `{getSectionForEachMatchingEntry(entries, props.gedcom, ['OBJE'], imageDetails)}` + (line 350-355). + 4. Directly below this block, and strictly _before_ the `` timeline + rendering execution line, embed + `` directly + within the `` flow. Rely on the component's internal + conditional logic to render the outer item bounds. ### 3. Registry and Documentation Updates + #### [MODIFY] [README.md](../src/sidepanel/details/README.md) -* **Rationale**: Functions as the official index cataloging all available component views within the side panel details sub-package. Documenting the new file preserves architectural readability. -* **Implementation Steps**: + +- **Rationale**: Functions as the official index cataloging all available + component views within the side panel details sub-package. Documenting the new + file preserves architectural readability. +- **Implementation Steps**: 1. Locate the alphabetical files registry list. - 2. Insert a descriptive entry for `immediate-family.tsx`, explaining its role as a dedicated side panel module for grouping and displaying parents, spouses, and children as rapid-navigation links. + 2. Insert a descriptive entry for `immediate-family.tsx`, explaining its role + as a dedicated side panel module for grouping and displaying parents, + spouses, and children as rapid-navigation links. ### 4. Localization Contracts + #### [MODIFY] Translation JSON Files (`src/translations/*.json`) -* **Rationale**: Guarantees that every new UI header label introduced by the feature supports multi-language localization cleanly across all supported international environments. -* **Implementation Steps**: - 1. Establish explicit string descriptor keys to be mapped across all JSON locale files (e.g., `src/translations/en.json`, `de.json`, `pl.json`, `fr.json`, `it.json`, etc.) to avoid production fallback leakage: - * `"family.immediate_family": "Immediate Family"` - * `"family.parents": "Parents"` - * `"family.father": "Father"` - * `"family.mother": "Mother"` - * `"family.spouse": "Spouse"` - * `"family.unknown_spouse": "Unknown Spouse"` - * `"family.children": "Children"` - 2. Ensure corresponding `` invocations inside `immediate-family.tsx` reference these specific string descriptor keys. + +- **Rationale**: Guarantees that every new UI header label introduced by the + feature supports multi-language localization cleanly across all supported + international environments. +- **Implementation Steps**: + 1. Establish explicit string descriptor keys to be mapped across all JSON + locale files (e.g., `src/translations/en.json`, `de.json`, `pl.json`, + `fr.json`, `it.json`, etc.) to avoid production fallback leakage: + - `"family.immediate_family": "Immediate Family"` + - `"family.parents": "Parents"` + - `"family.father": "Father"` + - `"family.mother": "Mother"` + - `"family.spouse": "Spouse"` + - `"family.unknown_spouse": "Unknown Spouse"` + - `"family.children": "Children"` + 2. Ensure corresponding `` invocations inside + `immediate-family.tsx` reference these specific string descriptor keys. diff --git a/docs/LOAD_FROM_GOOGLE_DRIVE.md b/docs/LOAD_FROM_GOOGLE_DRIVE.md index a326af8..9f00d1e 100644 --- a/docs/LOAD_FROM_GOOGLE_DRIVE.md +++ b/docs/LOAD_FROM_GOOGLE_DRIVE.md @@ -1,273 +1,530 @@ # Load from Google Drive ## Problem Statement -Currently, Topola Viewer operates primarily on local file uploads, external HTTP URLs, or integrations with APIs like WikiTree. However, users who maintain their genealogy files (such as `.ged` and `.gdz` files) on cloud storage platforms like Google Drive face significant friction when trying to view, share, or collaborate on their family trees. The lack of integrated cloud storage support makes sharing interactive trees between collaborators cumbersome, requiring them to manually download and re-upload files. Integrating Google Drive directly into Topola Viewer as a secure, read-only storage provider will allow users to seamlessly load their trees from the cloud and easily collaborate using direct shared links. + +Currently, Topola Viewer operates primarily on local file uploads, external HTTP +URLs, or integrations with APIs like WikiTree. However, users who maintain their +genealogy files (such as `.ged` and `.gdz` files) on cloud storage platforms +like Google Drive face significant friction when trying to view, share, or +collaborate on their family trees. The lack of integrated cloud storage support +makes sharing interactive trees between collaborators cumbersome, requiring them +to manually download and re-upload files. Integrating Google Drive directly into +Topola Viewer as a secure, read-only storage provider will allow users to +seamlessly load their trees from the cloud and easily collaborate using direct +shared links. ## The Technical Plan -To support Google Drive files, the application needs to orchestrate authentication, file selection, and download. The integration consists of five main components working together: +To support Google Drive files, the application needs to orchestrate +authentication, file selection, and download. The integration consists of five +main components working together: ### Major Components -1. **Google Drive Service**: This is a helper component that manages the connection to Google's APIs. It loads the Google libraries, triggers the login popup, caches the authorization token, and opens the Google Picker file selector. -2. **Google Drive Data Source**: A data loader that is registered in Topola's data source system. When the app is asked to load a file ID, this component uses the active authorization token to download the raw file content directly from Google Drive's secure servers. -3. **App Router / Shell**: The main orchestrator of the application. It parses parameters from the URL (like `fileId` and `source=google-drive`) and initiates the loading process. If the file download fails due to access issues, it manages the state of the fallback dialog. -4. **Access Authorization Modal**: A fallback dialog that displays when a user clicks a shared link to a Google Drive file that they do not yet have permission to view. It guides them to authenticate and select the target file using the Google Picker, which dynamically grants the app permission to access that file. -5. **Google Drive Menu**: A button added to Topola Viewer's menu bar that allows users to connect their Google account and browse their Drive files. +1. **Google Drive Service**: This is a helper component that manages the + connection to Google's APIs. It loads the Google libraries, triggers the + login popup, caches the authorization token, and opens the Google Picker file + selector. +2. **Google Drive Data Source**: A data loader that is registered in Topola's + data source system. When the app is asked to load a file ID, this component + uses the active authorization token to download the raw file content directly + from Google Drive's secure servers. +3. **App Router / Shell**: The main orchestrator of the application. It parses + parameters from the URL (like `fileId` and `source=google-drive`) and + initiates the loading process. If the file download fails due to access + issues, it manages the state of the fallback dialog. +4. **Access Authorization Modal**: A fallback dialog that displays when a user + clicks a shared link to a Google Drive file that they do not yet have + permission to view. It guides them to authenticate and select the target file + using the Google Picker, which dynamically grants the app permission to + access that file. +5. **Google Drive Menu**: A button added to Topola Viewer's menu bar that allows + users to connect their Google account and browse their Drive files. ## Alternatives Considered and Rejected -During the design phase, several alternative approaches were considered but rejected due to security, privacy, or complexity concerns: +During the design phase, several alternative approaches were considered but +rejected due to security, privacy, or complexity concerns: ### 1. Using the Broad `drive.readonly` Scope -* **Alternative**: Requesting permission to read any file in the user's Google Drive so that shared links could be opened immediately without additional user interaction. -* **Why Rejected**: Google classifies `drive.readonly` as a restricted scope. Publishing an app using this scope requires an annual third-party security assessment (CASA audit) which is prohibitively expensive for an open-source, non-profit project. Using the `drive.file` scope keeps the application within the non-sensitive tier while protecting user privacy by only granting access to files the user explicitly selects. + +- **Alternative**: Requesting permission to read any file in the user's Google + Drive so that shared links could be opened immediately without additional user + interaction. +- **Why Rejected**: Google classifies `drive.readonly` as a restricted scope. + Publishing an app using this scope requires an annual third-party security + assessment (CASA audit) which is prohibitively expensive for an open-source, + non-profit project. Using the `drive.file` scope keeps the application within + the non-sensitive tier while protecting user privacy by only granting access + to files the user explicitly selects. ### 2. Anonymous Downloads for Publicly Shared Files -* **Alternative**: Attempting to download files shared with "anyone with the link" directly using standard public URLs without requiring a Google sign-in. -* **Why Rejected**: Public Google Drive URLs do not reliably support browser CORS headers for anonymous requests, and larger files trigger virus-warning pages that return HTML rather than raw file contents. Requiring a Google login for all Google Drive operations allows the app to fetch via the official API with an authorization token, which avoids both CORS and virus-scan issues and simplifies the code. + +- **Alternative**: Attempting to download files shared with "anyone with the + link" directly using standard public URLs without requiring a Google sign-in. +- **Why Rejected**: Public Google Drive URLs do not reliably support browser + CORS headers for anonymous requests, and larger files trigger virus-warning + pages that return HTML rather than raw file contents. Requiring a Google login + for all Google Drive operations allows the app to fetch via the official API + with an authorization token, which avoids both CORS and virus-scan issues and + simplifies the code. ### 3. Routing Google Drive Traffic Through a CORS Proxy -* **Alternative**: Using the existing `topolaproxy.bieda.it` proxy to download files in order to bypass CORS restrictions. -* **Why Rejected**: Routing traffic through a third-party proxy poses a severe security risk because it would require transmitting the user's Google OAuth access token to an external service. Additionally, family tree files contain sensitive personal information, and routing them through an unauthenticated proxy violates privacy-first principles. Direct client-side requests to `googleapis.com` ensure all traffic remains securely encrypted between the user's browser and Google. + +- **Alternative**: Using the existing `topolaproxy.bieda.it` proxy to download + files in order to bypass CORS restrictions. +- **Why Rejected**: Routing traffic through a third-party proxy poses a severe + security risk because it would require transmitting the user's Google OAuth + access token to an external service. Additionally, family tree files contain + sensitive personal information, and routing them through an unauthenticated + proxy violates privacy-first principles. Direct client-side requests to + `googleapis.com` ensure all traffic remains securely encrypted between the + user's browser and Google. ### 4. Implementing Write/Edit Support -* **Alternative**: Allowing users to modify their family trees and write back the changes directly to Google Drive. -* **Why Rejected**: Topola Viewer is architected as a read-only visualization tool. Implementing full write support would require developing a robust GEDCOM and GEDZIP serialization engine, state mutation synchronization, and conflict resolution mechanisms. Limiting this feature to read-only viewing aligns with the application's core purpose and avoids significant, high-risk complexity. + +- **Alternative**: Allowing users to modify their family trees and write back + the changes directly to Google Drive. +- **Why Rejected**: Topola Viewer is architected as a read-only visualization + tool. Implementing full write support would require developing a robust GEDCOM + and GEDZIP serialization engine, state mutation synchronization, and conflict + resolution mechanisms. Limiting this feature to read-only viewing aligns with + the application's core purpose and avoids significant, high-risk complexity. ## Detailed Implementation Plan -This section outlines every file that will be created or modified to implement the Google Drive storage feature, along with the technical rationale for each change. +This section outlines every file that will be created or modified to implement +the Google Drive storage feature, along with the technical rationale for each +change. --- ### 1. Build and Dependencies Setup #### [MODIFY] [package.json](../package.json) -* **Rationale**: Type safety is critical for global variables introduced by Google scripts. -* **Changes**: Add the following type definitions to `devDependencies`: - * `@types/gapi` for the legacy Google API client. - * `@types/google.picker` for the Google Picker API. - * `@types/google.accounts` for the modern Google Identity Services (GIS) token client. -*Note: In contrast to the initial design where GAPI and GIS scripts were loaded statically in `index.html`, the final implementation loads these scripts dynamically on demand using the custom `loadScript()` helper function in `google_drive_service.ts` to improve performance and only download third-party assets when the user interacts with Google Drive.* +- **Rationale**: Type safety is critical for global variables introduced by + Google scripts. +- **Changes**: Add the following type definitions to `devDependencies`: + - `@types/gapi` for the legacy Google API client. + - `@types/google.picker` for the Google Picker API. + - `@types/google.accounts` for the modern Google Identity Services (GIS) token + client. +_Note: In contrast to the initial design where GAPI and GIS scripts were loaded +statically in `index.html`, the final implementation loads these scripts +dynamically on demand using the custom `loadScript()` helper function in +`google_drive_service.ts` to improve performance and only download third-party +assets when the user interacts with Google Drive._ --- ### 2. Data Source Layer #### [MODIFY] [src/datasource/data_source.ts](../src/datasource/data_source.ts) -* **Rationale**: Topola Viewer abstracts data loading using the `DataSource` interface and `DataSourceEnum`. We must register the Google Drive type here. -* **Changes**: Add a `GOOGLE_DRIVE` entry to the `DataSourceEnum` export. + +- **Rationale**: Topola Viewer abstracts data loading using the `DataSource` + interface and `DataSourceEnum`. We must register the Google Drive type here. +- **Changes**: Add a `GOOGLE_DRIVE` entry to the `DataSourceEnum` export. #### [MODIFY] [src/datasource/load_data.ts](../src/datasource/load_data.ts) -* **Rationale**: Restructure file loading logic so that Google Drive datasource can parse file content and perform initial caching without code duplication. -* **Changes**: Export a new helper function, `loadAndPrepareFile(blob: Blob, cacheId: string): Promise`, which calls `loadFile()` and `prepareData()` inside a try-catch block, handling `revokeObjectUrls()` in case of parsing errors. + +- **Rationale**: Restructure file loading logic so that Google Drive datasource + can parse file content and perform initial caching without code duplication. +- **Changes**: Export a new helper function, + `loadAndPrepareFile(blob: Blob, cacheId: string): Promise`, which + calls `loadFile()` and `prepareData()` inside a try-catch block, handling + `revokeObjectUrls()` in case of parsing errors. #### [NEW] [src/datasource/google_drive.ts](../src/datasource/google_drive.ts) -* **Rationale**: Defines the logic for checking and loading Google Drive files. -* **Changes**: - * Define the interface `GoogleDriveSourceSpec` that contains the `fileId` and a `source` field mapped to `DataSourceEnum.GOOGLE_DRIVE`. - * Create `GoogleDriveAuthError` (extends `Error`) to signal authentication failures or access denial back to the App controller. - * Implement the `GoogleDriveDataSource` class matching the `DataSource` interface. The `loadData` function: - 1. Checks `sessionStorage` under `google-drive:{fileId}` to see if the parsed file is already cached (avoiding network requests on page refresh). - 2. If not cached, gets the active access token from `googleDriveService`. If missing or expired, throws `GoogleDriveAuthError`. - 3. Executes a direct `fetch` to Google's REST endpoint (`https://www.googleapis.com/drive/v3/files/{fileId}?alt=media`) using the token. - 4. Inspects response status: if 401, 403, or 404, throws `GoogleDriveAuthError`; if any other non-200 code, throws a standard `Error`. - 5. Parses the downloaded blob using `loadFile` (reusing existing GEDCOM/GEDZIP zip parsing). + +- **Rationale**: Defines the logic for checking and loading Google Drive files. +- **Changes**: + - Define the interface `GoogleDriveSourceSpec` that contains the `fileId` and + a `source` field mapped to `DataSourceEnum.GOOGLE_DRIVE`. + - Create `GoogleDriveAuthError` (extends `Error`) to signal authentication + failures or access denial back to the App controller. + - Implement the `GoogleDriveDataSource` class matching the `DataSource` + interface. The `loadData` function: + 1. Checks `sessionStorage` under `google-drive:{fileId}` to see if the + parsed file is already cached (avoiding network requests on page + refresh). + 2. If not cached, gets the active access token from `googleDriveService`. If + missing or expired, throws `GoogleDriveAuthError`. + 3. Executes a direct `fetch` to Google's REST endpoint + (`https://www.googleapis.com/drive/v3/files/{fileId}?alt=media`) using + the token. + 4. Inspects response status: if 401, 403, or 404, throws + `GoogleDriveAuthError`; if any other non-200 code, throws a standard + `Error`. + 5. Parses the downloaded blob using `loadFile` (reusing existing + GEDCOM/GEDZIP zip parsing). #### [NEW] [src/datasource/google_drive_service.ts](../src/datasource/google_drive_service.ts) -* **Rationale**: Keeps all interactions with Google Identity Services and GAPI initialization isolated, protecting the core app from dependency leakage. -* **Changes**: - * Create a singleton `googleDriveService` class. - * Implement an initialization method `init()` that dynamically loads GAPI and GIS scripts using a helper function `loadScript(src)`. It then loads GAPI's `picker` library via `gapi.load('picker', ...)`. Access `window.gapi` and `window.google` using direct global references or `(window as any)` casting to prevent TypeScript compilation errors. - * Handle OAuth initialization via `google.accounts.oauth2.initTokenClient` and store the access token in memory. Cache the token and its computed absolute expiration timestamp (`Date.now() + expires_in * 1000`) in `sessionStorage` to survive page refreshes in the same tab. - * **Asynchronous Initialization**: Store the initialization Promise as `this.initPromise` (which resolves when global scripts are loaded and GAPI/GIS are initialized). All public service methods must await `this.initPromise` before executing to prevent race conditions and runtime errors. - * **Promise-based OAuth Request**: Since `initTokenClient` accepts a single static callback, `requestToken` should store the current Promise's `resolve` and `reject` handlers on the service instance (`this.pendingAuthResolve` and `this.pendingAuthReject`) and invoke them inside the GIS callback. - * Provide helper methods: - * `getAccessToken()`: Returns the cached token if it has not expired (checked against the absolute expiration timestamp stored in `sessionStorage`). - * `requestToken(forceAccountSelect)`: Returns a Promise that resolves when the Google Identity Services popup completes auth. If `forceAccountSelect` is true, sets the GIS configuration `prompt` parameter to `'select_account'` (critical for switching accounts on 403 errors). - * `showPicker(onPicked, onCancel)`: Calculates display dimensions based on the window size (`Math.min` bounds) to ensure responsiveness on mobile, then builds and opens the Google Picker. - * Set the developer API key via `.setDeveloperKey(import.meta.env.VITE_GOOGLE_API_KEY)` on the `PickerBuilder`. - * Configure `DocsView` using `google.picker.ViewId.DOCS` and restrict it to files matching `.setMimeTypes('application/x-gedcom,text/vnd.familysearch.gedcom,application/x-zip')` to cover `.ged` and `.gdz` formats. - * Set the origin using `.setOrigin(window.location.origin)` to prevent cross-origin issues between the Picker iframe and the viewer page. - * In the Picker callback, check for `data.action === google.picker.Action.PICKED` and retrieve the file ID via `data[google.picker.Response.DOCUMENTS][0][google.picker.Document.ID]`, triggering `onPicked`. - * If `data.action === google.picker.Action.CANCEL`, trigger the `onCancel` callback (if provided) to handle dialog closing gracefully. - * `signOut()`: Revokes the token using `google.accounts.oauth2.revoke`, clears memory and `sessionStorage` tokens, and resets local state. + +- **Rationale**: Keeps all interactions with Google Identity Services and GAPI + initialization isolated, protecting the core app from dependency leakage. +- **Changes**: + - Create a singleton `googleDriveService` class. + - Implement an initialization method `init()` that dynamically loads GAPI and + GIS scripts using a helper function `loadScript(src)`. It then loads GAPI's + `picker` library via `gapi.load('picker', ...)`. Access `window.gapi` and + `window.google` using direct global references or `(window as any)` casting + to prevent TypeScript compilation errors. + - Handle OAuth initialization via `google.accounts.oauth2.initTokenClient` and + store the access token in memory. Cache the token and its computed absolute + expiration timestamp (`Date.now() + expires_in * 1000`) in `sessionStorage` + to survive page refreshes in the same tab. + - **Asynchronous Initialization**: Store the initialization Promise as + `this.initPromise` (which resolves when global scripts are loaded and + GAPI/GIS are initialized). All public service methods must await + `this.initPromise` before executing to prevent race conditions and runtime + errors. + - **Promise-based OAuth Request**: Since `initTokenClient` accepts a single + static callback, `requestToken` should store the current Promise's `resolve` + and `reject` handlers on the service instance (`this.pendingAuthResolve` and + `this.pendingAuthReject`) and invoke them inside the GIS callback. + - Provide helper methods: + - `getAccessToken()`: Returns the cached token if it has not expired + (checked against the absolute expiration timestamp stored in + `sessionStorage`). + - `requestToken(forceAccountSelect)`: Returns a Promise that resolves when + the Google Identity Services popup completes auth. If `forceAccountSelect` + is true, sets the GIS configuration `prompt` parameter to + `'select_account'` (critical for switching accounts on 403 errors). + - `showPicker(onPicked, onCancel)`: Calculates display dimensions based on + the window size (`Math.min` bounds) to ensure responsiveness on mobile, + then builds and opens the Google Picker. + - Set the developer API key via + `.setDeveloperKey(import.meta.env.VITE_GOOGLE_API_KEY)` on the + `PickerBuilder`. + - Configure `DocsView` using `google.picker.ViewId.DOCS` and restrict it + to files matching + `.setMimeTypes('application/x-gedcom,text/vnd.familysearch.gedcom,application/x-zip')` + to cover `.ged` and `.gdz` formats. + - Set the origin using `.setOrigin(window.location.origin)` to prevent + cross-origin issues between the Picker iframe and the viewer page. + - In the Picker callback, check for + `data.action === google.picker.Action.PICKED` and retrieve the file ID + via + `data[google.picker.Response.DOCUMENTS][0][google.picker.Document.ID]`, + triggering `onPicked`. + - If `data.action === google.picker.Action.CANCEL`, trigger the `onCancel` + callback (if provided) to handle dialog closing gracefully. + - `signOut()`: Revokes the token using `google.accounts.oauth2.revoke`, + clears memory and `sessionStorage` tokens, and resets local state. #### [NEW] [src/datasource/test_helpers.ts](../src/datasource/test_helpers.ts) -* **Rationale**: Provides a mock `sessionStorage` utility for test environments. -* **Changes**: - * Define `mockSessionStorage()` which mocks `global.sessionStorage` with a key-value dictionary and returns it for inspection. + +- **Rationale**: Provides a mock `sessionStorage` utility for test environments. +- **Changes**: + - Define `mockSessionStorage()` which mocks `global.sessionStorage` with a + key-value dictionary and returns it for inspection. #### [NEW] [tests/import_meta_transformer.js](../tests/import_meta_transformer.js) -* **Rationale**: Translates Vite's `import.meta.env` to `process.env` so that tests running in Jest (a Node environment) can correctly access configuration properties. + +- **Rationale**: Translates Vite's `import.meta.env` to `process.env` so that + tests running in Jest (a Node environment) can correctly access configuration + properties. #### [MODIFY] [jest.config.ts](../jest.config.ts) -* **Rationale**: Registers the custom `import_meta_transformer.js` for TS and TSX files. + +- **Rationale**: Registers the custom `import_meta_transformer.js` for TS and + TSX files. #### [NEW] [src/datasource/google_drive.spec.ts](../src/datasource/google_drive.spec.ts) -* **Rationale**: Unit testing for the `GoogleDriveDataSource` class, verifying that the data source is instantiated, fetches, and parses data correctly, handles cached items, and throws authentication errors when expected. + +- **Rationale**: Unit testing for the `GoogleDriveDataSource` class, verifying + that the data source is instantiated, fetches, and parses data correctly, + handles cached items, and throws authentication errors when expected. #### [NEW] [src/datasource/google_drive_service.spec.ts](../src/datasource/google_drive_service.spec.ts) -* **Rationale**: Unit testing for the `GoogleDriveService` class including script loading, token caching, account selection, token revocation, and cache clearing. +- **Rationale**: Unit testing for the `GoogleDriveService` class including + script loading, token caching, account selection, token revocation, and cache + clearing. --- ### 3. User Interface Integration #### [NEW] [src/menu/google_drive_menu.tsx](../src/menu/google_drive_menu.tsx) -* **Rationale**: Provides the user entry point in the navigation bar to open files. -* **Changes**: - * Implement `GoogleDriveMenu` using the `MenuItem` abstraction. - * When clicked, it requests an OAuth access token via `googleDriveService`. If successful, it launches the Google Picker. - * When a file is selected, it extracts the ID and navigates to `/view` with search query params set to `source=google-drive&fileId={id}`. + +- **Rationale**: Provides the user entry point in the navigation bar to open + files. +- **Changes**: + - Implement `GoogleDriveMenu` using the `MenuItem` abstraction. + - When clicked, it requests an OAuth access token via `googleDriveService`. If + successful, it launches the Google Picker. + - When a file is selected, it extracts the ID and navigates to `/view` with + search query params set to `source=google-drive&fileId={id}`. #### [MODIFY] [src/menu/top_bar.tsx](../src/menu/top_bar.tsx) -* **Rationale**: TopBar contains open actions for uploads, URL loading, and WikiTree. We must inject the Google Drive options here. -* **Changes**: - * Add props: `onGoogleSignOut?: () => void`, `hasGoogleToken: boolean`, and `onGoogleTokenAcquired?: () => void`. - * Check if `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` exist in the build environment. - * If present, render `GoogleDriveMenu` alongside other menus in the desktop and mobile file selectors. - * Inject a **Google Drive Sign Out / Disconnect** button into the top bar in the same location where WikiTree login/logout options appear (on the right-aligned side of the menu for desktop, and list for mobile), rendering only if `hasGoogleToken` is true. Clicking it triggers a sign-out flow that: - 1. Calls the `onGoogleSignOut` callback. + +- **Rationale**: TopBar contains open actions for uploads, URL loading, and + WikiTree. We must inject the Google Drive options here. +- **Changes**: + - Add props: `onGoogleSignOut?: () => void`, `hasGoogleToken: boolean`, and + `onGoogleTokenAcquired?: () => void`. + - Check if `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` exist in the + build environment. + - If present, render `GoogleDriveMenu` alongside other menus in the desktop + and mobile file selectors. + - Inject a **Google Drive Sign Out / Disconnect** button into the top bar in + the same location where WikiTree login/logout options appear (on the + right-aligned side of the menu for desktop, and list for mobile), rendering + only if `hasGoogleToken` is true. Clicking it triggers a sign-out flow that: + 1. Calls the `onGoogleSignOut` callback. #### [NEW] [src/menu/google_auth_modal.tsx](../src/menu/google_auth_modal.tsx) -* **Rationale**: Essential for private file sharing support under `drive.file` and bypasses browser popup blockers. -* **Changes**: - * Define props interface: - * `failedFileId: string`: The ID of the file that failed to load. - * `onAuthSuccess: (fileId: string) => void`: Callback triggered when permission is successfully granted or a file is picked. - * `onCancel: () => void`: Callback triggered when the user cancels the modal. - * Create a modal view overlay displayed in the center of the screen. - * Prompt: "Google Drive Access Required". - * Provides a button to initiate connection. The modal implements a two-tier strategy tailored to `drive.file` constraints: - 1. Clicking the button first runs a quick OAuth request (`googleDriveService.requestToken()`) and attempts to download the target file ID. Due to `drive.file` limitations, this direct request will fail (with 403 or 404) on the first try if the app has not been authorized for this file yet in the user's Google Drive. - 2. In case of access failure (403/404), the modal UI will display detailed instructions guiding the user to select the file manually using the Google Picker. Selecting the file in the Picker explicitly grants the app permission to read it. - 3. **Shared Link Constraint**: If the file was shared via "Anyone with the link can view", it may not appear in the user's Picker search or "Shared with me" list until they open the link once in Google Drive or add a shortcut to "My Drive". The modal should document this instruction to guide users opening shared links. - * **Popup Blocker Handling**: The OAuth popup is only triggered when the user explicitly clicks the "Connect" button (a direct user gesture). The modal must never attempt to open the OAuth popup automatically on mount. - * **Non-blocking Loader**: When the login flow is pending, show a spinner on the button itself or a cancelable loader. Do not show an un-cancelable full-screen loading overlay, as Google Identity Services does not notify the app if the user closes the sign-in popup. The connect button must not be permanently disabled while loading; it should allow the user to click it again to retry or provide a manual reset/timeout in case they closed the login popup. - * **Switch Account Button**: Provides a "Switch Account" button that allows users to authenticate with a different Google account directly from the modal dialog. In the implementation, this button is rendered conditionally once picker instructions are visible (after a failed direct download attempt). - * **Cancel Action**: Provide a "Cancel" button. If clicked, calls `props.onCancel()` which redirects the user back to the homepage `/` (Intro). +- **Rationale**: Essential for private file sharing support under `drive.file` + and bypasses browser popup blockers. +- **Changes**: + - Define props interface: + - `failedFileId: string`: The ID of the file that failed to load. + - `onAuthSuccess: (fileId: string) => void`: Callback triggered when + permission is successfully granted or a file is picked. + - `onCancel: () => void`: Callback triggered when the user cancels the + modal. + - Create a modal view overlay displayed in the center of the screen. + - Prompt: "Google Drive Access Required". + - Provides a button to initiate connection. The modal implements a two-tier + strategy tailored to `drive.file` constraints: + 1. Clicking the button first runs a quick OAuth request + (`googleDriveService.requestToken()`) and attempts to download the target + file ID. Due to `drive.file` limitations, this direct request will fail + (with 403 or 404) on the first try if the app has not been authorized for + this file yet in the user's Google Drive. + 2. In case of access failure (403/404), the modal UI will display detailed + instructions guiding the user to select the file manually using the + Google Picker. Selecting the file in the Picker explicitly grants the app + permission to read it. + 3. **Shared Link Constraint**: If the file was shared via "Anyone with the + link can view", it may not appear in the user's Picker search or "Shared + with me" list until they open the link once in Google Drive or add a + shortcut to "My Drive". The modal should document this instruction to + guide users opening shared links. + - **Popup Blocker Handling**: The OAuth popup is only triggered when the user + explicitly clicks the "Connect" button (a direct user gesture). The modal + must never attempt to open the OAuth popup automatically on mount. + - **Non-blocking Loader**: When the login flow is pending, show a spinner on + the button itself or a cancelable loader. Do not show an un-cancelable + full-screen loading overlay, as Google Identity Services does not notify the + app if the user closes the sign-in popup. The connect button must not be + permanently disabled while loading; it should allow the user to click it + again to retry or provide a manual reset/timeout in case they closed the + login popup. + - **Switch Account Button**: Provides a "Switch Account" button that allows + users to authenticate with a different Google account directly from the + modal dialog. In the implementation, this button is rendered conditionally + once picker instructions are visible (after a failed direct download + attempt). + - **Cancel Action**: Provide a "Cancel" button. If clicked, calls + `props.onCancel()` which redirects the user back to the homepage `/` + (Intro). --- ### 4. Main App Orchestration #### [MODIFY] [src/app.tsx](../src/app.tsx) -* **Rationale**: The central application container responsible for routing, state tracking, error displaying, and data orchestration. -* **Changes**: - * **Type & Registration**: Add `GoogleDriveSourceSpec` to the `DataSourceSpec` union. Instantiate `googleDriveDataSource` via `const googleDriveDataSource = new GoogleDriveDataSource();` and register it in both the `loadData()` and `isNewData()` switch statements in `app.tsx`. - * **Credentials Check & Error Handling**: During data loading, if `source=google-drive` but `VITE_GOOGLE_CLIENT_ID` or `VITE_GOOGLE_API_KEY` is missing from the environment, throw an error signaling that Google Drive integration is not configured. - * **"Open with" Query Parameter**: Add a root-level `useEffect` or route handler to inspect the `state` query parameter using React Router's `location.search` hook. If present: - 1. Wrap the query parsing logic in a `try/catch` block to handle cases where the `state` parameter is not valid JSON (e.g. from other OAuth providers). - 2. Check that the parsed JSON contains Google Drive specific keys (`action === 'open'` and a non-empty `ids` array) before triggering the redirect. - 3. If valid, extract the first file ID (`ids[0]`) from the array and ignore any other IDs. Perform a client-side redirect (soft navigate) to `/view?source=google-drive&fileId={fileId}` using `{ replace: true }` so it does not pollute the browser history and break the back button. - 4. Clear the `state` query parameter from the URL by performing the client-side redirect using `{ replace: true }`, which replaces the URL in the history and implicitly clears the `state` parameter to prevent infinite redirect loops on subsequent rendering and routing cycles. - * **Argument Parsing**: Update `getArguments()` to support `source=google-drive` and `fileId` query params, returning a `GoogleDriveSourceSpec`. - * **Google Auth State**: Add React state for showing the ``, storing the `failedFileId`, and tracking `hasGoogleToken` (updated upon successful login or logout to ensure proper reactivity in `TopBar`). - * **Catching Auth Errors**: Update the main `useEffect` data loading sequence. If `loadData()` throws `GoogleDriveAuthError`, do *not* set `state` to `AppState.ERROR` (which shows a scary red error banner). Instead, keep a clean background (e.g. keep `AppState.LOADING` or transition to a non-error background) and set `showAuthModal` to true. - * **Resolving Auth Fallback (Avoiding Deadlock)**: - * If the user selects a *new* file in the Picker, perform a soft `navigate` to update the URL parameters, triggering `isNewData` and initiating a normal reload. - * If the user successfully authorizes and selects the *same* file ID (matching `failedFileId`), do *not* navigate (as the URL matches and would result in a no-op). Instead, explicitly reset `state` to `AppState.INITIAL`. This forces the `useEffect` to execute the load sequence again using the newly acquired access token. - * If the user successfully selects the target file in the auth modal's callback, trigger url updating to refresh the page and successfully render the chart. - * **Disconnect Callback**: Pass `onGoogleSignOut` to `TopBar` that: - 1. Revokes the token using `googleDriveService.signOut()`. - 2. Clears the active `data` and `selection` state in `App` and revokes all media Object URLs. - 3. Clears the active `sessionStorage` cache (all keys starting with `google-drive:`) to prevent unauthorized access by subsequent users on shared/public devices. - 4. Redirects the user back to the home route `/` (Intro). + +- **Rationale**: The central application container responsible for routing, + state tracking, error displaying, and data orchestration. +- **Changes**: + - **Type & Registration**: Add `GoogleDriveSourceSpec` to the `DataSourceSpec` + union. Instantiate `googleDriveDataSource` via + `const googleDriveDataSource = new GoogleDriveDataSource();` and register it + in both the `loadData()` and `isNewData()` switch statements in `app.tsx`. + - **Credentials Check & Error Handling**: During data loading, if + `source=google-drive` but `VITE_GOOGLE_CLIENT_ID` or `VITE_GOOGLE_API_KEY` + is missing from the environment, throw an error signaling that Google Drive + integration is not configured. + - **"Open with" Query Parameter**: Add a root-level `useEffect` or route + handler to inspect the `state` query parameter using React Router's + `location.search` hook. If present: + 1. Wrap the query parsing logic in a `try/catch` block to handle cases where + the `state` parameter is not valid JSON (e.g. from other OAuth + providers). + 2. Check that the parsed JSON contains Google Drive specific keys + (`action === 'open'` and a non-empty `ids` array) before triggering the + redirect. + 3. If valid, extract the first file ID (`ids[0]`) from the array and ignore + any other IDs. Perform a client-side redirect (soft navigate) to + `/view?source=google-drive&fileId={fileId}` using `{ replace: true }` so + it does not pollute the browser history and break the back button. + 4. Clear the `state` query parameter from the URL by performing the + client-side redirect using `{ replace: true }`, which replaces the URL in + the history and implicitly clears the `state` parameter to prevent + infinite redirect loops on subsequent rendering and routing cycles. + - **Argument Parsing**: Update `getArguments()` to support + `source=google-drive` and `fileId` query params, returning a + `GoogleDriveSourceSpec`. + - **Google Auth State**: Add React state for showing the + ``, storing the `failedFileId`, and tracking + `hasGoogleToken` (updated upon successful login or logout to ensure proper + reactivity in `TopBar`). + - **Catching Auth Errors**: Update the main `useEffect` data loading sequence. + If `loadData()` throws `GoogleDriveAuthError`, do _not_ set `state` to + `AppState.ERROR` (which shows a scary red error banner). Instead, keep a + clean background (e.g. keep `AppState.LOADING` or transition to a non-error + background) and set `showAuthModal` to true. + - **Resolving Auth Fallback (Avoiding Deadlock)**: + - If the user selects a _new_ file in the Picker, perform a soft `navigate` + to update the URL parameters, triggering `isNewData` and initiating a + normal reload. + - If the user successfully authorizes and selects the _same_ file ID + (matching `failedFileId`), do _not_ navigate (as the URL matches and would + result in a no-op). Instead, explicitly reset `state` to + `AppState.INITIAL`. This forces the `useEffect` to execute the load + sequence again using the newly acquired access token. + - If the user successfully selects the target file in the auth modal's + callback, trigger url updating to refresh the page and successfully render + the chart. + - **Disconnect Callback**: Pass `onGoogleSignOut` to `TopBar` that: + 1. Revokes the token using `googleDriveService.signOut()`. + 2. Clears the active `data` and `selection` state in `App` and revokes all + media Object URLs. + 3. Clears the active `sessionStorage` cache (all keys starting with + `google-drive:`) to prevent unauthorized access by subsequent users on + shared/public devices. + 4. Redirects the user back to the home route `/` (Intro). --- ### 5. Localization Integration -#### [MODIFY] [src/translations/*.json](../src/translations) and [src/app.tsx](../src/app.tsx) -* **Rationale**: Ensure all user-facing Google Drive integration UI strings are fully translated and localized. -* **Changes**: Add translation keys for the new UI elements across all 7 localization files (`bg.json`, `cs.json`, `de.json`, `fr.json`, `it.json`, `pl.json`, `ru.json`). For the default English catalog, declare messages using the `defaultMessage` prop in components or as arguments in `intl.formatMessage` calls. Use a `TopolaError` with code `'GOOGLE_DRIVE_NOT_CONFIGURED'` to propagate configuration errors so they can be parsed and translated by `getI18nMessage()`. - * `menu.load_from_google_drive`: "Load from Google Drive" (or language equivalent) - * `menu.google_sign_out`: "Disconnect Google Drive" / "Sign out" - * `google_auth.title`: "Google Drive Access Required" - * `google_auth.instructions`: "To view this file, you must authenticate and select the file from your Google Drive to grant permissions." - * `google_auth.grant_button`: "Grant Access & Select File" - * `google_auth.cancel`: "Cancel" - * `google_auth.picker_instructions_header`: "Permissions Required" - * `google_auth.picker_instructions`: "The application does not have permission to read this file. Please select it in the file browser popup to grant access. If this is a shared file that doesn't show up, try adding a shortcut to your Drive first." - * `google_auth.switch_account_button`: "Switch Account" - * `error.GOOGLE_DRIVE_NOT_CONFIGURED`: "Google Drive integration is not configured." +#### [MODIFY] [src/translations/\*.json](../src/translations) and [src/app.tsx](../src/app.tsx) + +- **Rationale**: Ensure all user-facing Google Drive integration UI strings are + fully translated and localized. +- **Changes**: Add translation keys for the new UI elements across all 7 + localization files (`bg.json`, `cs.json`, `de.json`, `fr.json`, `it.json`, + `pl.json`, `ru.json`). For the default English catalog, declare messages using + the `defaultMessage` prop in components or as arguments in + `intl.formatMessage` calls. Use a `TopolaError` with code + `'GOOGLE_DRIVE_NOT_CONFIGURED'` to propagate configuration errors so they can + be parsed and translated by `getI18nMessage()`. + - `menu.load_from_google_drive`: "Load from Google Drive" (or language + equivalent) + - `menu.google_sign_out`: "Disconnect Google Drive" / "Sign out" + - `google_auth.title`: "Google Drive Access Required" + - `google_auth.instructions`: "To view this file, you must authenticate and + select the file from your Google Drive to grant permissions." + - `google_auth.grant_button`: "Grant Access & Select File" + - `google_auth.cancel`: "Cancel" + - `google_auth.picker_instructions_header`: "Permissions Required" + - `google_auth.picker_instructions`: "The application does not have permission + to read this file. Please select it in the file browser popup to grant + access. If this is a shared file that doesn't show up, try adding a shortcut + to your Drive first." + - `google_auth.switch_account_button`: "Switch Account" + - `error.GOOGLE_DRIVE_NOT_CONFIGURED`: "Google Drive integration is not + configured." --- ## Google Cloud Platform Setup Guide -To support authentication and file picking, Topola Viewer must be connected to a Google Cloud Platform (GCP) project. Below are the step-by-step instructions to configure the GCP resources, credentials, and Workspace Marketplace listings. +To support authentication and file picking, Topola Viewer must be connected to a +Google Cloud Platform (GCP) project. Below are the step-by-step instructions to +configure the GCP resources, credentials, and Workspace Marketplace listings. ### Step 1: Create a Google Cloud Project + 1. Go to the [Google Cloud Console](https://console.cloud.google.com/). 2. Click the project dropdown in the top navigation and select **New Project**. 3. Enter a name (e.g., `Topola Viewer`) and click **Create**. ### Step 2: Enable Required APIs + 1. Navigate to **APIs & Services** $\rightarrow$ **Library**. 2. Search for and enable the following APIs: - * **Google Drive API** (for file downloading). - * **Google Picker API** (for the file browser popup). - * **Google Workspace Marketplace SDK** (required to enable the "Open with" context menu inside Google Drive). + - **Google Drive API** (for file downloading). + - **Google Picker API** (for the file browser popup). + - **Google Workspace Marketplace SDK** (required to enable the "Open with" + context menu inside Google Drive). ### Step 3: Configure the OAuth Consent Screen + 1. Navigate to **APIs & Services** $\rightarrow$ **OAuth consent screen**. -2. Select **External** for User Type (so any Google user can log in) and click **Create**. -3. Complete the **App information** (App name, Support email, Developer contact information) and click **Save and Continue**. +2. Select **External** for User Type (so any Google user can log in) and click + **Create**. +3. Complete the **App information** (App name, Support email, Developer contact + information) and click **Save and Continue**. 4. In the **Scopes** step, click **Add or Remove Scopes**: - * Add the scope: `https://www.googleapis.com/auth/drive.file` - * This scope is classified as non-sensitive, meaning Topola Viewer does *not* require expensive security reviews or CASA audits to be verified by Google. + - Add the scope: `https://www.googleapis.com/auth/drive.file` + - This scope is classified as non-sensitive, meaning Topola Viewer does _not_ + require expensive security reviews or CASA audits to be verified by Google. 5. Save and continue to finish the configuration. ### Step 4: Create OAuth 2.0 Credentials + 1. Navigate to **APIs & Services** $\rightarrow$ **Credentials**. 2. Click **Create Credentials** $\rightarrow$ **OAuth client ID**. 3. Set the **Application type** to **Web application**. -4. Under **Authorized JavaScript origins**, add the domains where the app is deployed, as well as local environments: - * `https://pewu.github.io` (Production) - * `https://apps.wikitree.com` (Wikitree Integration) - * `http://localhost:3000` (Local testing and development) - > [!IMPORTANT] - > Google's OAuth validation is strict. In local development, ensure you access the application via `http://localhost:3000` rather than `http://127.0.0.1:3000`, as using the raw IP address will result in origin authorization failures unless `http://127.0.0.1:3000` is also explicitly added to this list. -5. Click **Create**. Copy the generated **Client ID** to use in the app environment variables (`VITE_GOOGLE_CLIENT_ID`). +4. Under **Authorized JavaScript origins**, add the domains where the app is + deployed, as well as local environments: + - `https://pewu.github.io` (Production) + - `https://apps.wikitree.com` (Wikitree Integration) + - `http://localhost:3000` (Local testing and development) + > [!IMPORTANT] Google's OAuth validation is strict. In local development, + > ensure you access the application via `http://localhost:3000` rather than + > `http://127.0.0.1:3000`, as using the raw IP address will result in + > origin authorization failures unless `http://127.0.0.1:3000` is also + > explicitly added to this list. +5. Click **Create**. Copy the generated **Client ID** to use in the app + environment variables (`VITE_GOOGLE_CLIENT_ID`). ### Step 5: Create an API Key (for Google Picker) -1. On the **Credentials** screen, click **Create Credentials** $\rightarrow$ **API key**. + +1. On the **Credentials** screen, click **Create Credentials** $\rightarrow$ + **API key**. 2. Edit the newly created API key to add restrictions: - * **Application restrictions**: Choose **HTTP referrers (web sites)**. - * Add the authorized referrers: - * `https://pewu.github.io/topola/viewer/*` - * `https://apps.wikitree.com/apps/wiech13/topola-viewer/*` - * `http://localhost:3000/*` - * **API restrictions**: Restrict the key to only allow requests to **both** the **Google Picker API** and the **Google Drive API** (the Picker component queries user files via the Drive API under the hood; restricting it strictly to the Picker API will cause requests to fail). -3. Save the key. Copy the generated **API Key** to use in the app environment variables (`VITE_GOOGLE_API_KEY`). + - **Application restrictions**: Choose **HTTP referrers (web sites)**. + - Add the authorized referrers: + - `https://pewu.github.io/topola/viewer/*` + - `https://apps.wikitree.com/apps/wiech13/topola-viewer/*` + - `http://localhost:3000/*` + - **API restrictions**: Restrict the key to only allow requests to **both** + the **Google Picker API** and the **Google Drive API** (the Picker + component queries user files via the Drive API under the hood; restricting + it strictly to the Picker API will cause requests to fail). +3. Save the key. Copy the generated **API Key** to use in the app environment + variables (`VITE_GOOGLE_API_KEY`). ### Step 6: Configure the Workspace Marketplace SDK ("Open with" Integration) -1. Navigate to **APIs & Services** $\rightarrow$ **Enabled APIs & Services**, and select the **Google Workspace Marketplace SDK**. + +1. Navigate to **APIs & Services** $\rightarrow$ **Enabled APIs & Services**, + and select the **Google Workspace Marketplace SDK**. 2. Click **App Integration** $\rightarrow$ **Configuration**. 3. Enable the **Drive Extension** integration check. 4. Configure the **Open URL**: - * URL: `https://pewu.github.io/topola-viewer/` (or the corresponding deploy URL). + - URL: `https://pewu.github.io/topola-viewer/` (or the corresponding deploy + URL). 5. Set up **File Handlers**: - * Under **Default File Extensions**, register `.ged` and `.gdz`. -6. (Optional) Configure the **Store Listing** to publish the extension to the public Google Workspace Marketplace, making it searchable and installable for anyone. + - Under **Default File Extensions**, register `.ged` and `.gdz`. +6. (Optional) Configure the **Store Listing** to publish the extension to the + public Google Workspace Marketplace, making it searchable and installable for + anyone. ## Testing Strategy -Due to the dependency on third-party external Google APIs and credentials, testing this feature is divided into automated mocking (for CI environments and unit tests) and manual verification (for local development). +Due to the dependency on third-party external Google APIs and credentials, +testing this feature is divided into automated mocking (for CI environments and +unit tests) and manual verification (for local development). ### 1. Automated Unit Testing -All automated unit tests are run via **Jest**. Since Node environments do not load Google's external CDN scripts, we must isolate and mock these dependencies. +All automated unit tests are run via **Jest**. Since Node environments do not +load Google's external CDN scripts, we must isolate and mock these dependencies. #### Mocking Global Scripts -Create a mock utility in the tests setup file to intercept calls to the global `google` and `gapi` interfaces: + +Create a mock utility in the tests setup file to intercept calls to the global +`google` and `gapi` interfaces: + ```typescript global.gapi = { load: jest.fn((api: string, callback: () => void) => callback()), @@ -304,115 +561,200 @@ global.google = { ``` #### Data Source Tests + In `src/datasource/google_drive.spec.ts` [NEW]: -* **Verify `isNewData()`**: Confirm it returns true only when the `fileId` changes. -* **Verify `loadData()` Success**: Mock `window.fetch` to return a mock blob, mock `getAccessToken()` to return a dummy token, and verify the data source parses the file stream correctly. -* **Verify `loadData()` Auth Failure**: Mock `getAccessToken()` to return `null` or configure `window.fetch` to return `403 Forbidden`, and assert that `GoogleDriveAuthError` is thrown. + +- **Verify `isNewData()`**: Confirm it returns true only when the `fileId` + changes. +- **Verify `loadData()` Success**: Mock `window.fetch` to return a mock blob, + mock `getAccessToken()` to return a dummy token, and verify the data source + parses the file stream correctly. +- **Verify `loadData()` Auth Failure**: Mock `getAccessToken()` to return `null` + or configure `window.fetch` to return `403 Forbidden`, and assert that + `GoogleDriveAuthError` is thrown. #### URL Parsing & Orchestration Tests + In existing test suites or manual validation: -* Verify that URL query arguments containing `source=google-drive&fileId=XYZ` are parsed into the correct `GoogleDriveSourceSpec`. -* Verify that if `loadData()` throws `GoogleDriveAuthError`, the application transitions state, avoids setting a global error message, and triggers the rendering of the `` fallback popup. + +- Verify that URL query arguments containing `source=google-drive&fileId=XYZ` + are parsed into the correct `GoogleDriveSourceSpec`. +- Verify that if `loadData()` throws `GoogleDriveAuthError`, the application + transitions state, avoids setting a global error message, and triggers the + rendering of the `` fallback popup. --- ### 2. Manual Verification Checklist -Manual testing should be performed locally to verify the full OAuth loop and UI presentation: +Manual testing should be performed locally to verify the full OAuth loop and UI +presentation: -| Test Case | Action | Expected Result | -| :--- | :--- | :--- | -| **Missing Credentials** | Run the app with empty `VITE_GOOGLE_CLIENT_ID` / `VITE_GOOGLE_API_KEY` values. | The Google Drive option is completely hidden from the TopBar menus; local file upload and WikiTree operations continue working normally. | -| **Picker Load** | Click "Load from Google Drive" in the menu, log in when prompted, select a `.ged`/`.gdz` file. | Google Picker popup closes, selected file is fetched directly from Google APIs, URL hash updates, and the family chart renders. | -| **Shared Link Auth Fallback** | Load: `http://localhost:3000/#/view?source=google-drive&fileId=UNAUTHORIZED_ID` | The "Google Drive Access Required" modal overlays the screen, preventing rendering. | -| **Grant Access Flow** | Click "Grant Access" in the fallback modal, authenticate, and pick the matching file from "Shared with me". | Modal closes, OAuth token gets authorized for the specific file ID, tree downloads and renders successfully. | -| **Google Drive UI Open** | Navigate to: `http://localhost:3000/?state={"ids":["FILE_ID"],"action":"open"}` | The app detects the `state` param, automatically prompts Google authentication, downloads the file, and opens the chart. | -| **Invalid Selection** | Pick a non-matching file from the fallback modal's picker. | App detects the mismatch, updates the URL to the new file's ID, downloads the newly picked file, and displays the tree. | +| Test Case | Action | Expected Result | +| :---------------------------- | :---------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | +| **Missing Credentials** | Run the app with empty `VITE_GOOGLE_CLIENT_ID` / `VITE_GOOGLE_API_KEY` values. | The Google Drive option is completely hidden from the TopBar menus; local file upload and WikiTree operations continue working normally. | +| **Picker Load** | Click "Load from Google Drive" in the menu, log in when prompted, select a `.ged`/`.gdz` file. | Google Picker popup closes, selected file is fetched directly from Google APIs, URL hash updates, and the family chart renders. | +| **Shared Link Auth Fallback** | Load: `http://localhost:3000/#/view?source=google-drive&fileId=UNAUTHORIZED_ID` | The "Google Drive Access Required" modal overlays the screen, preventing rendering. | +| **Grant Access Flow** | Click "Grant Access" in the fallback modal, authenticate, and pick the matching file from "Shared with me". | Modal closes, OAuth token gets authorized for the specific file ID, tree downloads and renders successfully. | +| **Google Drive UI Open** | Navigate to: `http://localhost:3000/?state={"ids":["FILE_ID"],"action":"open"}` | The app detects the `state` param, automatically prompts Google authentication, downloads the file, and opens the chart. | +| **Invalid Selection** | Pick a non-matching file from the fallback modal's picker. | App detects the mismatch, updates the URL to the new file's ID, downloads the newly picked file, and displays the tree. | --- ### 3. Continuous Integration (CI) Safety -* In CI pipelines (e.g. GitHub Actions workflows), Google API keys are not present in the environment. -* Because the feature checks for the presence of environment variables at compile/build time, the Google Drive menus are automatically omitted. -* This ensures that visual regression tests (`npm run test:visual`) and E2E test runs (`npm run test:e2e`) pass without requiring live OAuth secrets or mock servers. +- In CI pipelines (e.g. GitHub Actions workflows), Google API keys are not + present in the environment. +- Because the feature checks for the presence of environment variables at + compile/build time, the Google Drive menus are automatically omitted. +- This ensures that visual regression tests (`npm run test:visual`) and E2E test + runs (`npm run test:e2e`) pass without requiring live OAuth secrets or mock + servers. --- ## Build and Deployment CI/CD Configuration -To enable the Google Drive integration on the official public deployments, the Google API credentials must be injected during the automated build process in GitHub Actions. +To enable the Google Drive integration on the official public deployments, the +Google API credentials must be injected during the automated build process in +GitHub Actions. ### Required GitHub Secrets -You must configure the following repository secrets in your GitHub project settings: -1. `GOOGLE_CLIENT_ID`: The official OAuth Client ID for `pewu.github.io` / `apps.wikitree.com`. + +You must configure the following repository secrets in your GitHub project +settings: + +1. `GOOGLE_CLIENT_ID`: The official OAuth Client ID for `pewu.github.io` / + `apps.wikitree.com`. 2. `GOOGLE_API_KEY`: The official Google Picker API Key. ### Workflow Modifications -The build step (`npm run build`) in the deployment workflows must receive these secrets as environment variables. +The build step (`npm run build`) in the deployment workflows must receive these +secrets as environment variables. #### 1. Deployment to GitHub Pages (`.github/workflows/deploy-gh-pages.yml`) + Pass the secrets to the build step: + ```yaml - - run: npm run build - env: - VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} +- run: npm run build + env: + VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} ``` #### 2. Deployment to WikiTree Apps (`.github/workflows/deploy-wikitree-apps.yml`) + Pass the secrets to the build step: + ```yaml - - run: npm run build - env: - VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} +- run: npm run build + env: + VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} ``` #### 3. Reusable Workflow Inheritance (`.github/workflows/deploy-everywhere.yml`) -Enable secret inheritance on jobs calling these workflows to ensure secrets are propagated: + +Enable secret inheritance on jobs calling these workflows to ensure secrets are +propagated: + ```yaml - deploy-gh-pages: - uses: ./.github/workflows/deploy-gh-pages.yml - secrets: inherit +deploy-gh-pages: + uses: ./.github/workflows/deploy-gh-pages.yml + secrets: inherit ``` --- ## Security and Privacy -Since Topola Viewer is a purely client-side application that manages sensitive genealogical data, security and user privacy are core design pillars for this integration. +Since Topola Viewer is a purely client-side application that manages sensitive +genealogical data, security and user privacy are core design pillars for this +integration. ### 1. Protection of Public API Credentials -The application's client-side architecture requires compiling the public Google `Client ID` and `API Key` directly into the JavaScript bundle. These values are protected from abuse using Google Cloud Platform constraints: -* **OAuth Domain Whitelisting**: The `Client ID` restricts **Authorized JavaScript Origins** and **Redirect URIs** to `https://pewu.github.io`, `https://apps.wikitree.com`, and `http://localhost:3000`. Google's authorization server will automatically block login attempts initiated from any other domains. -* **API Key Restrictions**: The `API Key` enforces **HTTP Referrer Restrictions** matching the authorized domains. Additionally, the key is scoped via **API Restrictions** to only permit requests to the **Google Picker API** and **Google Drive API**, preventing its use on other Google Cloud services. -* **No Secrets Compiled**: The application never utilizes or compiles an OAuth `Client Secret`, which is only required for server-side integrations. + +The application's client-side architecture requires compiling the public Google +`Client ID` and `API Key` directly into the JavaScript bundle. These values are +protected from abuse using Google Cloud Platform constraints: + +- **OAuth Domain Whitelisting**: The `Client ID` restricts **Authorized + JavaScript Origins** and **Redirect URIs** to `https://pewu.github.io`, + `https://apps.wikitree.com`, and `http://localhost:3000`. Google's + authorization server will automatically block login attempts initiated from + any other domains. +- **API Key Restrictions**: The `API Key` enforces **HTTP Referrer + Restrictions** matching the authorized domains. Additionally, the key is + scoped via **API Restrictions** to only permit requests to the **Google Picker + API** and **Google Drive API**, preventing its use on other Google Cloud + services. +- **No Secrets Compiled**: The application never utilizes or compiles an OAuth + `Client Secret`, which is only required for server-side integrations. ### 2. Data Privacy & Zero-Server Architecture -* **Direct Client-to-Google Communication**: All requests to Google Drive APIs are initiated directly from the user's browser. -* **No Third-Party Proxies**: To prevent token leakage and maintain data confidentiality, Google Drive downloads **never** route through the `topolaproxy.bieda.it` CORS proxy. -* **Local Parsing**: The fetched GEDCOM or GEDZIP file content is parsed entirely within the browser using local JavaScript. No family data, files, or access tokens are ever transmitted to, processed by, or stored on external servers. -* **Short-Lived Auth Tokens**: The application uses short-lived access tokens that expire automatically. No persistent refresh tokens are requested or stored. -* **Session Storage Cache Security**: To optimize load times, parsed tree data is cached in `sessionStorage` under `google-drive:{fileId}`. To protect user privacy on shared or public computers, signing out or disconnecting Google Drive explicitly purges all `google-drive:*` keys from the browser's `sessionStorage`. + +- **Direct Client-to-Google Communication**: All requests to Google Drive APIs + are initiated directly from the user's browser. +- **No Third-Party Proxies**: To prevent token leakage and maintain data + confidentiality, Google Drive downloads **never** route through the + `topolaproxy.bieda.it` CORS proxy. +- **Local Parsing**: The fetched GEDCOM or GEDZIP file content is parsed + entirely within the browser using local JavaScript. No family data, files, or + access tokens are ever transmitted to, processed by, or stored on external + servers. +- **Short-Lived Auth Tokens**: The application uses short-lived access tokens + that expire automatically. No persistent refresh tokens are requested or + stored. +- **Session Storage Cache Security**: To optimize load times, parsed tree data + is cached in `sessionStorage` under `google-drive:{fileId}`. To protect user + privacy on shared or public computers, signing out or disconnecting Google + Drive explicitly purges all `google-drive:*` keys from the browser's + `sessionStorage`. ### 3. Principle of Least Privilege (OAuth Scopes) -Instead of requesting the broad `drive.readonly` scope, which grants visibility into a user's entire Google Drive, this feature utilizes the restrictive `drive.file` scope. Under this scope: -* The application only has permission to view files that the user has explicitly opened with the app (either by selecting the file in the Google Picker or opening it via the "Open with" Google Drive UI option). -* Topola Viewer remains blind to all other files and directories in the user's Google Drive. + +Instead of requesting the broad `drive.readonly` scope, which grants visibility +into a user's entire Google Drive, this feature utilizes the restrictive +`drive.file` scope. Under this scope: + +- The application only has permission to view files that the user has explicitly + opened with the app (either by selecting the file in the Google Picker or + opening it via the "Open with" Google Drive UI option). +- Topola Viewer remains blind to all other files and directories in the user's + Google Drive. ## Known Limitations -1. **Image Loading from Plain `.ged` Files**: Only `.gdz` archives are supported for viewing media files from Google Drive. Plain `.ged` files will only display genealogical data, and any relative/external image paths will render as broken images because standard HTML `` tags cannot transmit the `Authorization` header required for private Google Drive requests. -2. **Iframe Embedding (WikiTree)**: Running Google Drive authentication and loading files from Google Drive inside an embedding iframe (such as on `apps.wikitree.com`) can fail due to iframe popup sandboxing and origin mismatch restrictions. This configuration is not actively supported. -3. **Session Refresh Cache**: When a page is refreshed, zipped images cached in `sessionStorage` might have invalid Object URLs. We choose to ignore this problem until it becomes a visible UX issue. -4. **Access Modal Lockout**: If a user logs into a Google account that does not have permissions to read the file, they are prompted with the "Access Required" modal. Since this modal blocks the viewport, they cannot click the top-bar "Disconnect" button to switch Google accounts, requiring them to click "Cancel" to return to the homepage first. +1. **Image Loading from Plain `.ged` Files**: Only `.gdz` archives are supported + for viewing media files from Google Drive. Plain `.ged` files will only + display genealogical data, and any relative/external image paths will render + as broken images because standard HTML `` tags cannot transmit the + `Authorization` header required for private Google Drive requests. +2. **Iframe Embedding (WikiTree)**: Running Google Drive authentication and + loading files from Google Drive inside an embedding iframe (such as on + `apps.wikitree.com`) can fail due to iframe popup sandboxing and origin + mismatch restrictions. This configuration is not actively supported. +3. **Session Refresh Cache**: When a page is refreshed, zipped images cached in + `sessionStorage` might have invalid Object URLs. We choose to ignore this + problem until it becomes a visible UX issue. +4. **Access Modal Lockout**: If a user logs into a Google account that does not + have permissions to read the file, they are prompted with the "Access + Required" modal. Since this modal blocks the viewport, they cannot click the + top-bar "Disconnect" button to switch Google accounts, requiring them to + click "Cancel" to return to the homepage first. --- ## Future Improvements -1. **Self-Hosting Runtime Configuration Support**: Read `google-client-id` and `google-api-key` from HTML `` tags at runtime (similar to `topola-static-url`), allowing self-hosters to deploy and configure Google Drive support without rebuilding the static assets. -2. **Fetch Cancellation & Race Condition Mitigation**: Implement an `AbortController` or active boolean flag cancellation check in `app.tsx`'s `useEffect` loader to ensure slow background downloads do not overwrite newer datasets if a user changes the source/file before the previous request finishes. - +1. **Self-Hosting Runtime Configuration Support**: Read `google-client-id` and + `google-api-key` from HTML `` tags at runtime (similar to + `topola-static-url`), allowing self-hosters to deploy and configure Google + Drive support without rebuilding the static assets. +2. **Fetch Cancellation & Race Condition Mitigation**: Implement an + `AbortController` or active boolean flag cancellation check in `app.tsx`'s + `useEffect` loader to ensure slow background downloads do not overwrite newer + datasets if a user changes the source/file before the previous request + finishes. diff --git a/docs/PLAYWRIGHT_DESIGN.md b/docs/PLAYWRIGHT_DESIGN.md index 8f2761a..66e1a47 100644 --- a/docs/PLAYWRIGHT_DESIGN.md +++ b/docs/PLAYWRIGHT_DESIGN.md @@ -2,13 +2,28 @@ ## 1. Business & Functional Problem Statement -Topola Viewer currently relies on a Cypress end-to-end (E2E) test suite to verify critical user flows, such as interactive chart rendering, search capabilities, and experimental WebMCP integrations. However, the existing setup is brittle and hard to maintain because it depends on live, external internet connections to fetch remote GEDCOM trees, uses loosely-typed JavaScript spec files, and suffers from complex process orchestration. Additionally, Cypress's historical architectural limitations make testing cross-origin iframe structures in embedded mode slow and difficult to simulate locally. Migrating this testing suite to Playwright in TypeScript will establish a fast, fully type-safe, hermetic, and self-contained validation pipeline, ensuring absolute quality control and deployment confidence. +Topola Viewer currently relies on a Cypress end-to-end (E2E) test suite to +verify critical user flows, such as interactive chart rendering, search +capabilities, and experimental WebMCP integrations. However, the existing setup +is brittle and hard to maintain because it depends on live, external internet +connections to fetch remote GEDCOM trees, uses loosely-typed JavaScript spec +files, and suffers from complex process orchestration. Additionally, Cypress's +historical architectural limitations make testing cross-origin iframe structures +in embedded mode slow and difficult to simulate locally. Migrating this testing +suite to Playwright in TypeScript will establish a fast, fully type-safe, +hermetic, and self-contained validation pipeline, ensuring absolute quality +control and deployment confidence. ## 2. The Technical Plan -The technical solution relies on transitioning our testing engine from Cypress to Playwright, utilizing a self-contained, local-first execution model. Instead of relying on external servers or separate terminal scripts to boot up our application, Playwright will orchestrate the entire lifecycle from start to finish. +The technical solution relies on transitioning our testing engine from Cypress +to Playwright, utilizing a self-contained, local-first execution model. Instead +of relying on external servers or separate terminal scripts to boot up our +application, Playwright will orchestrate the entire lifecycle from start to +finish. -Here is a block diagram of how the major components fit together during a test run: +Here is a block diagram of how the major components fit together during a test +run: ```mermaid graph TD @@ -43,224 +58,469 @@ graph TD ### Breakdown of Major Components #### 1. The Playwright Test Runner -The central brain of our testing environment. It handles parsing our TypeScript test specifications, managing test execution, starting the local web server, controlling the virtual browser instances, and generating reports. + +The central brain of our testing environment. It handles parsing our TypeScript +test specifications, managing test execution, starting the local web server, +controlling the virtual browser instances, and generating reports. #### 2. The Local Web Server (Vite / Preview) -A local server hosting the Topola Viewer application. To ensure E2E tests validate the exact production bundle that will be deployed, the test runner in CI environments boots the production-built files via Vite's preview server (`npx vite preview --port 3000 --strictPort`), with the build process decoupled and executed earlier in the CI workflow job. Local environments dynamically run Vite dev server (`npx vite --no-open --port 3000 --strictPort`) or reuse an existing running server to maintain fast iteration cycles. + +A local server hosting the Topola Viewer application. To ensure E2E tests +validate the exact production bundle that will be deployed, the test runner in +CI environments boots the production-built files via Vite's preview server +(`npx vite preview --port 3000 --strictPort`), with the build process decoupled +and executed earlier in the CI workflow job. Local environments dynamically run +Vite dev server (`npx vite --no-open --port 3000 --strictPort`) or reuse an +existing running server to maintain fast iteration cycles. #### 3. The Built-in Network Router (Interception) -The central network traffic controller. When the application attempts to fetch the family tree, our network router intercepts the request and returns our offline sample data instead. To prevent browser CORS blocks, the mock response explicitly supplies the `Access-Control-Allow-Origin: *` header. Wildcard interceptors match any request ending with `/family.ged`, allowing E2E tests to load family tree data consistently from local mock data without hardcoding public GitHub URL paths or relying on CORS proxy overrides. + +The central network traffic controller. When the application attempts to fetch +the family tree, our network router intercepts the request and returns our +offline sample data instead. To prevent browser CORS blocks, the mock response +explicitly supplies the `Access-Control-Allow-Origin: *` header. Wildcard +interceptors match any request ending with `/family.ged`, allowing E2E tests to +load family tree data consistently from local mock data without hardcoding +public GitHub URL paths or relying on CORS proxy overrides. #### 4. The Virtual Page Generator (Iframe Wrapper) -A mechanism for generating temporary webpage wrappers directly in-memory during test execution. To simulate how our app works inside an iframe, Playwright serves a dynamic wrapper page on a mock URL `/test-embedded-frame.html`. This wrapper page loads our app inside an iframe and handles standard bidirectional postMessage handshakes. Because the application uses `HashRouter`, the iframe source points to the hash route `/#/view?embedded=true&handleCors=false` to ensure React Router matches the `/view` route. + +A mechanism for generating temporary webpage wrappers directly in-memory during +test execution. To simulate how our app works inside an iframe, Playwright +serves a dynamic wrapper page on a mock URL `/test-embedded-frame.html`. This +wrapper page loads our app inside an iframe and handles standard bidirectional +postMessage handshakes. Because the application uses `HashRouter`, the iframe +source points to the hash route `/#/view?embedded=true&handleCors=false` to +ensure React Router matches the `/view` route. #### 5. The Browser Environment Injector (Init Scripts) -Allows the test runner to pre-configure the browser's environment before the web page's own code executes. This is essential for simulating experimental features like WebMCP tool registration. The injector exposes a mock registration API that pushes registered tools to the browser's global `window.__registeredTools` array. The runner evaluates execution blocks *inside* the browser context to invoke these tools' non-serializable callbacks, asserting tool registration and visual UI updates. + +Allows the test runner to pre-configure the browser's environment before the web +page's own code executes. This is essential for simulating experimental features +like WebMCP tool registration. The injector exposes a mock registration API that +pushes registered tools to the browser's global `window.__registeredTools` +array. The runner evaluates execution blocks _inside_ the browser context to +invoke these tools' non-serializable callbacks, asserting tool registration and +visual UI updates. ## 3. Alternatives Considered & Rejected -To establish clear architectural guardrails and prevent future regressions or redundant work, this section documents the key design alternatives that were evaluated and subsequently ruled out. +To establish clear architectural guardrails and prevent future regressions or +redundant work, this section documents the key design alternatives that were +evaluated and subsequently ruled out. ### A. Phased Migration (Coexistence) -* **Considered:** Running both Cypress and Playwright concurrently in the repository and migrating the five test specs one by one over time. -* **Rejected because:** Dual-framework coexistence introduces significant developer friction and technical debt. Developers would have to maintain duplicate configuration files, manage dual dependency pools in `package.json`, and configure complex dual execution stages in GitHub Actions. Given our E2E suite is compact, a "clean break" eliminates Cypress-related dependencies immediately. + +- **Considered:** Running both Cypress and Playwright concurrently in the + repository and migrating the five test specs one by one over time. +- **Rejected because:** Dual-framework coexistence introduces significant + developer friction and technical debt. Developers would have to maintain + duplicate configuration files, manage dual dependency pools in `package.json`, + and configure complex dual execution stages in GitHub Actions. Given our E2E + suite is compact, a "clean break" eliminates Cypress-related dependencies + immediately. ### B. Running E2E Tests Against Live URLs (Real-World Feeds) -* **Considered:** Continuing the Cypress pattern of loading the sample family tree directly from GitHub raw servers over the public internet. -* **Rejected because:** Live URL calls in automated E2E tests are a primary source of test flakiness. Tests can fail randomly due to DNS resolution lags, server downtime, or GitHub API rate-limiting, causing false negatives in our CI pipeline. Using Playwright's network routing to intercept these calls and fulfill them with a local fixture guarantees absolute hermeticity and consistency. + +- **Considered:** Continuing the Cypress pattern of loading the sample family + tree directly from GitHub raw servers over the public internet. +- **Rejected because:** Live URL calls in automated E2E tests are a primary + source of test flakiness. Tests can fail randomly due to DNS resolution lags, + server downtime, or GitHub API rate-limiting, causing false negatives in our + CI pipeline. Using Playwright's network routing to intercept these calls and + fulfill them with a local fixture guarantees absolute hermeticity and + consistency. ### C. Maintaining Physical HTML Wrapper Files for Iframe Tests -* **Considered:** Storing a physical `tests/fixtures/embedded_frame.html` file in the repository to act as the container for iframe tests. -* **Rejected because:** Storing a physical, standalone test-only wrapper file can create environment port synchronization issues and static asset mapping overhead. -* **Actual Implementation Note:** The implementation adopted a hybrid approach. A physical template file [embedded_frame.html](../tests/fixtures/embedded_frame.html) is maintained as the structural source of truth for the frame, but it is loaded in-memory and served virtually on `/test-embedded-frame.html` via the network router, keeping it on the same origin/port dynamically to bypass cross-origin iframe blocks. + +- **Considered:** Storing a physical `tests/fixtures/embedded_frame.html` file + in the repository to act as the container for iframe tests. +- **Rejected because:** Storing a physical, standalone test-only wrapper file + can create environment port synchronization issues and static asset mapping + overhead. +- **Actual Implementation Note:** The implementation adopted a hybrid approach. + A physical template file + [embedded_frame.html](../tests/fixtures/embedded_frame.html) is maintained as + the structural source of truth for the frame, but it is loaded in-memory and + served virtually on `/test-embedded-frame.html` via the network router, + keeping it on the same origin/port dynamically to bypass cross-origin iframe + blocks. ### D. Retaining `start-server-and-test` for Dev Server Bootstrapping -* **Considered:** Continuing to rely on `start-server-and-test` or a custom bash script to verify when port `3000` is responsive before running tests. -* **Rejected because:** Playwright's native `webServer` orchestrator is superior, highly optimized, and self-contained. It handles port polling, processes recycling on failures, and performs automatic process cleanups upon exit natively. Keeping `start-server-and-test` adds unnecessary external library dependencies and script complexity. + +- **Considered:** Continuing to rely on `start-server-and-test` or a custom bash + script to verify when port `3000` is responsive before running tests. +- **Rejected because:** Playwright's native `webServer` orchestrator is + superior, highly optimized, and self-contained. It handles port polling, + processes recycling on failures, and performs automatic process cleanups upon + exit natively. Keeping `start-server-and-test` adds unnecessary external + library dependencies and script complexity. ### E. Initial Multi-Browser & Multi-Device Targets -* **Considered:** Configuring Chromium, Firefox, WebKit, and mobile emulation from Day One. -* **Rejected because:** The immediate goal is to achieve full, stable parity with the existing single-browser Cypress suite. Adding multiple engines right away increases execution time and CI resource consumption. Multi-browser testing can be easily enabled later by adjusting the configuration in `playwright.config.ts`. + +- **Considered:** Configuring Chromium, Firefox, WebKit, and mobile emulation + from Day One. +- **Rejected because:** The immediate goal is to achieve full, stable parity + with the existing single-browser Cypress suite. Adding multiple engines right + away increases execution time and CI resource consumption. Multi-browser + testing can be easily enabled later by adjusting the configuration in + `playwright.config.ts`. ## 4. Detailed Implementation Plan -This section defines the granular step-by-step instructions and enumerates **every single file** that will be created, modified, or deleted to ensure a flawless migration, including complete, copy-pasteable code contents for all configurations and test specifications matching the actual implementation. +This section defines the granular step-by-step instructions and enumerates +**every single file** that will be created, modified, or deleted to ensure a +flawless migration, including complete, copy-pasteable code contents for all +configurations and test specifications matching the actual implementation. ### A. Enumeration of Files #### 1. Files to [DELETE] -* **[cypress.config.ts](../cypress.config.ts)** - * *Rationale:* Complete removal of Cypress configurations; no longer needed. -* **[cypress/e2e/chart_view.cy.js](../cypress/e2e/chart_view.cy.js)** - * *Rationale:* Outdated JavaScript test spec. Replaced by type-safe `tests/chart_view.spec.ts`. -* **[cypress/e2e/embedded.cy.js](../cypress/e2e/embedded.cy.js)** - * *Rationale:* Outdated JavaScript test spec. Replaced by type-safe `tests/embedded.spec.ts`. -* **[cypress/e2e/intro.cy.js](../cypress/e2e/intro.cy.js)** - * *Rationale:* Outdated JavaScript test spec. Replaced by type-safe `tests/intro.spec.ts`. -* **[cypress/e2e/search.cy.js](../cypress/e2e/search.cy.js)** - * *Rationale:* Outdated JavaScript test spec. Replaced by type-safe `tests/search.spec.ts`. -* **[cypress/e2e/webmcp.cy.js](../cypress/e2e/webmcp.cy.js)** - * *Rationale:* Outdated JavaScript test spec. Replaced by type-safe `tests/webmcp.spec.ts`. -* **`cypress/e2e/README.md`** - * *Rationale:* Legacy test documentation that is no longer applicable. +- **[cypress.config.ts](../cypress.config.ts)** + - _Rationale:_ Complete removal of Cypress configurations; no longer needed. +- **[cypress/e2e/chart_view.cy.js](../cypress/e2e/chart_view.cy.js)** + - _Rationale:_ Outdated JavaScript test spec. Replaced by type-safe + `tests/chart_view.spec.ts`. +- **[cypress/e2e/embedded.cy.js](../cypress/e2e/embedded.cy.js)** + - _Rationale:_ Outdated JavaScript test spec. Replaced by type-safe + `tests/embedded.spec.ts`. +- **[cypress/e2e/intro.cy.js](../cypress/e2e/intro.cy.js)** + - _Rationale:_ Outdated JavaScript test spec. Replaced by type-safe + `tests/intro.spec.ts`. +- **[cypress/e2e/search.cy.js](../cypress/e2e/search.cy.js)** + - _Rationale:_ Outdated JavaScript test spec. Replaced by type-safe + `tests/search.spec.ts`. +- **[cypress/e2e/webmcp.cy.js](../cypress/e2e/webmcp.cy.js)** + - _Rationale:_ Outdated JavaScript test spec. Replaced by type-safe + `tests/webmcp.spec.ts`. +- **`cypress/e2e/README.md`** + - _Rationale:_ Legacy test documentation that is no longer applicable. #### 2. Files to [MODIFY] -* **[package.json](../package.json)** - * *Rationale:* Uninstall `cypress` and `start-server-and-test` devDependencies. Install `@playwright/test` and `@types/node`. Add the silent `"preview": "vite preview"` script, replace the `cy:*` test scripts with Playwright E2E execution targets (`"test:e2e": "playwright test"` and `"test:e2e:ui": "playwright test --ui"`), and update the `"prettier"` and `"lint"` scripts to format and lint both the `src/` and `tests/` directories. -* **[jest.config.ts](../jest.config.ts)** - * *Rationale:* Add the `roots` configuration property to isolate Jest unit testing to the `src/` directory, preventing Jest from scanning or executing Playwright specs inside the `tests/` folder. -* **[.gitignore](../.gitignore)** - * *Rationale:* Ignore locally generated Playwright E2E testing artifacts (`playwright-report/`, `test-results/`, and `.playwright/`) to maintain a clean working tree. -* **[PROJECT_STRUCTURE.md](../PROJECT_STRUCTURE.md)** - * *Rationale:* Replace references to Cypress (`cypress/`, `cypress.config.ts`) with Playwright (`tests/`, `playwright.config.ts`) to keep the repository structural documentation fully accurate. -* **[.github/workflows/node.js.yml](../.github/workflows/node.js.yml)** - * *Rationale:* Replace `npm run cy:start-and-run` with cached Playwright execution. E2E tests are executed across all matrix Node environments to maximize testing coverage and parity across runtimes. -* **[.github/workflows/deploy-gh-pages.yml](../.github/workflows/deploy-gh-pages.yml)** - * *Rationale:* Remove testing and browser-setup steps entirely to speed up the deployment flow and separate deploy actions from regular test gating. -* **[.github/workflows/deploy-wikitree-apps.yml](../.github/workflows/deploy-wikitree-apps.yml)** - * *Rationale:* Remove testing and browser-setup steps entirely to speed up the deployment flow and separate deploy actions from regular test gating. +- **[package.json](../package.json)** + - _Rationale:_ Uninstall `cypress` and `start-server-and-test` + devDependencies. Install `@playwright/test` and `@types/node`. Add the + silent `"preview": "vite preview"` script, replace the `cy:*` test scripts + with Playwright E2E execution targets (`"test:e2e": "playwright test"` and + `"test:e2e:ui": "playwright test --ui"`), and update the `"prettier"` and + `"lint"` scripts to format and lint both the `src/` and `tests/` + directories. +- **[jest.config.ts](../jest.config.ts)** + - _Rationale:_ Add the `roots` configuration property to isolate Jest unit + testing to the `src/` directory, preventing Jest from scanning or executing + Playwright specs inside the `tests/` folder. +- **[.gitignore](../.gitignore)** + - _Rationale:_ Ignore locally generated Playwright E2E testing artifacts + (`playwright-report/`, `test-results/`, and `.playwright/`) to maintain a + clean working tree. +- **[PROJECT_STRUCTURE.md](../PROJECT_STRUCTURE.md)** + - _Rationale:_ Replace references to Cypress (`cypress/`, `cypress.config.ts`) + with Playwright (`tests/`, `playwright.config.ts`) to keep the repository + structural documentation fully accurate. +- **[.github/workflows/node.js.yml](../.github/workflows/node.js.yml)** + - _Rationale:_ Replace `npm run cy:start-and-run` with cached Playwright + execution. E2E tests are executed across all matrix Node environments to + maximize testing coverage and parity across runtimes. +- **[.github/workflows/deploy-gh-pages.yml](../.github/workflows/deploy-gh-pages.yml)** + - _Rationale:_ Remove testing and browser-setup steps entirely to speed up the + deployment flow and separate deploy actions from regular test gating. +- **[.github/workflows/deploy-wikitree-apps.yml](../.github/workflows/deploy-wikitree-apps.yml)** + - _Rationale:_ Remove testing and browser-setup steps entirely to speed up the + deployment flow and separate deploy actions from regular test gating. #### 3. Files to [NEW] -* **[playwright.config.ts](../playwright.config.ts)** - * *Rationale:* Central configuration for Playwright. Configures parallel execution, defines a single Desktop Chrome (Chromium) project, sets the base URL and locale to `'en-US'`, and manages the local dev or preview servers dynamically on port 3000 with strict port enforcement. -* **`tests/tsconfig.json`** - * *Rationale:* Custom localized compiler configuration for E2E tests that extends the root `tsconfig.json` but includes NodeJS environment types explicitly and isolates global types from Jest, keeping E2E environments perfectly isolated. It sets `"noEmit": true` because E2E tests do not need to output compiled JS files. -* **`tests/global.d.ts`** - * *Rationale:* Custom global type declaration file for E2E tests to safely declare `__registeredTools` on the `Window` interface without TypeScript compiler warnings. Redundant overrides for `Navigator` are omitted because the test suite inherits it from the application's core WebMCP declarations. -* **`tests/helpers.ts`** - * *Rationale:* Shared test utilities to encapsulate wildcard route mocking for family tree fetching (`setupGedcomRoute`) and hermetic context routing (`setupHermeticEnvironment`). This avoids code duplication across spec files. -* **`tests/fixtures/embedded_frame.html`** - * *Rationale:* Physical template wrapper file defining the iframe and message-passing structure for embedded view E2E verification. -* **`src/datasource/testdata/test.ged`** - * *Rationale:* Instead of checking in a duplicate fixture file, we directly read the existing version-controlled test GEDCOM dataset located at `src/datasource/testdata/test.ged` inside the Playwright interceptors. This eliminates file duplication, reduces repository footprint, and ensures a single source of truth. -* **`tests/intro.spec.ts`** - * *Rationale:* Type-safe TS spec checking landing page layout, menu items, and basic static DOM presence. -* **`tests/chart_view.spec.ts`** - * *Rationale:* Type-safe TS spec checking interactive tree navigation, relying on Playwright's auto-waiting to settle D3 transitions, drawer details panels, and routing interception to block analytics/third-party APIs. -* **`tests/search.spec.ts`** - * *Rationale:* Type-safe TS spec checking the search autocompletion (using robust, user-facing locators like `page.getByPlaceholder('Search for people')` to target the input, and Playwright's auto-waiting to handle search debouncing). -* **`tests/webmcp.spec.ts`** - * *Rationale:* Type-safe TS spec verifying WebMCP tools. Emulates out-of-process tool executions inside `navigator.modelContext` by evaluating execution blocks within the browser context, using polling assertions to avoid React `useEffect` mount race conditions. -* **`tests/embedded.spec.ts`** - * *Rationale:* Type-safe TS spec verifying iframe embedded views using a virtually served template file `tests/fixtures/embedded_frame.html` executing the bidirectional postMessage handshake (`ready` / `gedcom`) matching production. +- **[playwright.config.ts](../playwright.config.ts)** + - _Rationale:_ Central configuration for Playwright. Configures parallel + execution, defines a single Desktop Chrome (Chromium) project, sets the base + URL and locale to `'en-US'`, and manages the local dev or preview servers + dynamically on port 3000 with strict port enforcement. +- **`tests/tsconfig.json`** + - _Rationale:_ Custom localized compiler configuration for E2E tests that + extends the root `tsconfig.json` but includes NodeJS environment types + explicitly and isolates global types from Jest, keeping E2E environments + perfectly isolated. It sets `"noEmit": true` because E2E tests do not need + to output compiled JS files. +- **`tests/global.d.ts`** + - _Rationale:_ Custom global type declaration file for E2E tests to safely + declare `__registeredTools` on the `Window` interface without TypeScript + compiler warnings. Redundant overrides for `Navigator` are omitted because + the test suite inherits it from the application's core WebMCP declarations. +- **`tests/helpers.ts`** + - _Rationale:_ Shared test utilities to encapsulate wildcard route mocking for + family tree fetching (`setupGedcomRoute`) and hermetic context routing + (`setupHermeticEnvironment`). This avoids code duplication across spec + files. +- **`tests/fixtures/embedded_frame.html`** + - _Rationale:_ Physical template wrapper file defining the iframe and + message-passing structure for embedded view E2E verification. +- **`src/datasource/testdata/test.ged`** + - _Rationale:_ Instead of checking in a duplicate fixture file, we directly + read the existing version-controlled test GEDCOM dataset located at + `src/datasource/testdata/test.ged` inside the Playwright interceptors. This + eliminates file duplication, reduces repository footprint, and ensures a + single source of truth. +- **`tests/intro.spec.ts`** + - _Rationale:_ Type-safe TS spec checking landing page layout, menu items, and + basic static DOM presence. +- **`tests/chart_view.spec.ts`** + - _Rationale:_ Type-safe TS spec checking interactive tree navigation, relying + on Playwright's auto-waiting to settle D3 transitions, drawer details + panels, and routing interception to block analytics/third-party APIs. +- **`tests/search.spec.ts`** + - _Rationale:_ Type-safe TS spec checking the search autocompletion (using + robust, user-facing locators like + `page.getByPlaceholder('Search for people')` to target the input, and + Playwright's auto-waiting to handle search debouncing). +- **`tests/webmcp.spec.ts`** + - _Rationale:_ Type-safe TS spec verifying WebMCP tools. Emulates + out-of-process tool executions inside `navigator.modelContext` by evaluating + execution blocks within the browser context, using polling assertions to + avoid React `useEffect` mount race conditions. +- **`tests/embedded.spec.ts`** + - _Rationale:_ Type-safe TS spec verifying iframe embedded views using a + virtually served template file `tests/fixtures/embedded_frame.html` + executing the bidirectional postMessage handshake (`ready` / `gedcom`) + matching production. ### B. Step-by-Step Execution Plan & Complete File Contents #### Step 1: Dependency Purge & Clean Break + 1. Remove legacy Cypress and server-tester modules from `package.json`: `npm uninstall cypress start-server-and-test` -2. Delete the `cypress.config.ts` configuration and the recursive `cypress/` folder from disk. +2. Delete the `cypress.config.ts` configuration and the recursive `cypress/` + folder from disk. 3. Install Playwright Test Framework and Node environment types: `npm install -D @playwright/test @types/node` 4. Download the local browser binaries required for Playwright execution: `npx playwright install chromium` -5. Update the `"prettier"` script in `package.json` to cover both `src/` and `tests/`: - `"prettier": "prettier --write \"{src,tests}/**/*.{ts,tsx,json}\""` -6. Modify `jest.config.ts` to isolate Jest testing to the `src/` directory and prevent test scanning collisions: +5. Update the `"prettier"` script in `package.json` to cover both `src/` and + `tests/`: `"prettier": "prettier --write \"{src,tests}/**/*.{ts,tsx,json}\""` +6. Modify `jest.config.ts` to isolate Jest testing to the `src/` directory and + prevent test scanning collisions: ```typescript // Add this property to the exported config object in jest.config.ts roots: ["/src"], ``` -7. Update the repository structural documentation [PROJECT_STRUCTURE.md](../PROJECT_STRUCTURE.md) to replace Cypress details with Playwright details. +7. Update the repository structural documentation + [PROJECT_STRUCTURE.md](../PROJECT_STRUCTURE.md) to replace Cypress details + with Playwright details. #### Step 2: Script Alignment & Silent Configs -1. Update the `package.json` scripts: - * Add `"preview": "vite preview"` to support serving the production build in CI. - * Replace `cy:*` script targets with Playwright E2E commands: - * `"test:e2e": "playwright test"` - * `"test:e2e:ui": "playwright test --ui"` -2. Author `playwright.config.ts` to orchestrate the `webServer` dynamically based on execution context, and poll port `3000`. -**Key Configuration Details for [playwright.config.ts](../playwright.config.ts):** -* **Test Directory**: Target `./tests` folder. -* **Parallelism & CI Tuning**: Enable fully parallel execution (`fullyParallel: true`), disable `forbidOnly` locally but enforce it in CI, and configure retries (2 in CI, 0 locally). -* **Base Configuration**: Set the `baseURL` to `http://localhost:3000`, force the locale to `'en-US'` to ensure consistent translation keys across all runs, and capture traces on first retry. -* **Browsers**: Set up a single project using standard Chromium devices (`Desktop Chrome`). -* **Orchestrated WebServer**: Configure `webServer` to run the Vite dev server (`npx vite --no-open --port 3000 --strictPort`) locally or Vite preview (`npx vite preview --port 3000 --strictPort`) in CI, targeting port `3000` with strict port enforcement and reusing any existing local server instance only in local mode. +1. Update the `package.json` scripts: + - Add `"preview": "vite preview"` to support serving the production build in + CI. + - Replace `cy:*` script targets with Playwright E2E commands: + - `"test:e2e": "playwright test"` + - `"test:e2e:ui": "playwright test --ui"` +2. Author `playwright.config.ts` to orchestrate the `webServer` dynamically + based on execution context, and poll port `3000`. + +**Key Configuration Details for +[playwright.config.ts](../playwright.config.ts):** + +- **Test Directory**: Target `./tests` folder. +- **Parallelism & CI Tuning**: Enable fully parallel execution + (`fullyParallel: true`), disable `forbidOnly` locally but enforce it in CI, + and configure retries (2 in CI, 0 locally). +- **Base Configuration**: Set the `baseURL` to `http://localhost:3000`, force + the locale to `'en-US'` to ensure consistent translation keys across all runs, + and capture traces on first retry. +- **Browsers**: Set up a single project using standard Chromium devices + (`Desktop Chrome`). +- **Orchestrated WebServer**: Configure `webServer` to run the Vite dev server + (`npx vite --no-open --port 3000 --strictPort`) locally or Vite preview + (`npx vite preview --port 3000 --strictPort`) in CI, targeting port `3000` + with strict port enforcement and reusing any existing local server instance + only in local mode. #### Step 3: Establish Test Directories & Compile Settings + 1. Create the folder path `tests/` and its subfolder `tests/fixtures/`. -2. Author `tests/tsconfig.json` to isolate test typings from the main application and Jest unit tests: - * **Inheritance & Target**: Extends the root `tsconfig.json`, setting `module` and `moduleResolution` to `NodeNext` and targeting `ES2022`. - * **Isolated Types**: Excludes Jest/app global typings and explicitly pulls in only the `"node"` types. - * **No Output**: Sets `"noEmit": true` as E2E specs do not need compilation output. -3. Author `tests/global.d.ts` to provide TypeScript type definitions for mocked window objects: - * **Type Extension**: Declares `__registeredTools?` on the global `Window` interface to prevent TypeScript compilation errors during WebMCP mocks. -4. Author `tests/helpers.ts` to provide reusable mock setups and routing interceptions: - * **Hermetic Routing**: Implements a `setupHermeticEnvironment(context)` helper that intercepts tracking services (`**/*google-analytics.com/**`, `**/*googletagmanager.com/**`) and embeds baseline web fonts to guarantee deterministic and fast test execution. - * **GEDCOM Mocks**: Implements a `setupGedcomRoute(context)` helper that reads the version-controlled local dataset (`src/datasource/testdata/test.ged`) and routes all requests matching `**/family.ged` to be fulfilled with it, serving a `200 OK` response with CORS enablement headers (`Access-Control-Allow-Origin: *`). -5. Author the physical template wrapper file `tests/fixtures/embedded_frame.html` for testing embedded iframe communications: - * **Structure**: Defines a standard wrapper document housing an iframe that points to the app's embedded route: `/#/view?embedded=true&handleCors=false`. - * **Bidirectional Handshake**: Contains script block executing a `fetch()` to get the GEDCOM content. It listens for a `'ready'` postMessage from the child iframe, and posts the raw GEDCOM content back with a `'gedcom'` message once the handshake is complete. +2. Author `tests/tsconfig.json` to isolate test typings from the main + application and Jest unit tests: + - **Inheritance & Target**: Extends the root `tsconfig.json`, setting + `module` and `moduleResolution` to `NodeNext` and targeting `ES2022`. + - **Isolated Types**: Excludes Jest/app global typings and explicitly pulls + in only the `"node"` types. + - **No Output**: Sets `"noEmit": true` as E2E specs do not need compilation + output. +3. Author `tests/global.d.ts` to provide TypeScript type definitions for mocked + window objects: + - **Type Extension**: Declares `__registeredTools?` on the global `Window` + interface to prevent TypeScript compilation errors during WebMCP mocks. +4. Author `tests/helpers.ts` to provide reusable mock setups and routing + interceptions: + - **Hermetic Routing**: Implements a `setupHermeticEnvironment(context)` + helper that intercepts tracking services (`**/*google-analytics.com/**`, + `**/*googletagmanager.com/**`) and embeds baseline web fonts to guarantee + deterministic and fast test execution. + - **GEDCOM Mocks**: Implements a `setupGedcomRoute(context)` helper that + reads the version-controlled local dataset + (`src/datasource/testdata/test.ged`) and routes all requests matching + `**/family.ged` to be fulfilled with it, serving a `200 OK` response with + CORS enablement headers (`Access-Control-Allow-Origin: *`). +5. Author the physical template wrapper file + `tests/fixtures/embedded_frame.html` for testing embedded iframe + communications: + - **Structure**: Defines a standard wrapper document housing an iframe that + points to the app's embedded route: + `/#/view?embedded=true&handleCors=false`. + - **Bidirectional Handshake**: Contains script block executing a `fetch()` to + get the GEDCOM content. It listens for a `'ready'` postMessage from the + child iframe, and posts the raw GEDCOM content back with a `'gedcom'` + message once the handshake is complete. #### Step 4: Spec Translation & Spec Designs ##### 1. Intro Test (`tests/intro.spec.ts`) + Checks the landing page layout, menu items, and basic static DOM presence: -* **Setup**: Leverages `beforeEach` to configure hermetic routes using the `setupHermeticEnvironment` helper, then loads the index page (`/`). -* **Assertions**: - * Verifies that the main intro landing text content (specifically checking for the presence of `'Examples'`) is visible on the page. - * Asserts that core action buttons in the menu (exact text `'Open file'` and `'Load from URL'`) are properly rendered and visible to the user. + +- **Setup**: Leverages `beforeEach` to configure hermetic routes using the + `setupHermeticEnvironment` helper, then loads the index page (`/`). +- **Assertions**: + - Verifies that the main intro landing text content (specifically checking for + the presence of `'Examples'`) is visible on the page. + - Asserts that core action buttons in the menu (exact text `'Open file'` and + `'Load from URL'`) are properly rendered and visible to the user. ##### 2. Chart View Test (`tests/chart_view.spec.ts`) -Checks tree navigation, settles transitions, details panels, and routing interception: -* **Setup**: Employs `beforeEach` to configure route intercepts via `setupGedcomRoute` and navigates to the app passing a mock URL parameter (`/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged`). -* **Assertions**: - * Verifies that the viewer loads and renders the chart nodes, asserting that the name `'Bonifacy'` is visible inside the `#content` container. - * Tests chart interactive capabilities by clicking an individual node (e.g., `'Radobod'`) with `{force: true}` to navigate, asserting that a child node (e.g., `'Chike'`) animates and renders. - * Verifies side-drawer details rendering by asserting that person-specific data (e.g., `'a random note'`) is visible. + +Checks tree navigation, settles transitions, details panels, and routing +interception: + +- **Setup**: Employs `beforeEach` to configure route intercepts via + `setupGedcomRoute` and navigates to the app passing a mock URL parameter + (`/#/view?url=https%3A%2F%2Fexample.org%2Ffamily.ged`). +- **Assertions**: + - Verifies that the viewer loads and renders the chart nodes, asserting that + the name `'Bonifacy'` is visible inside the `#content` container. + - Tests chart interactive capabilities by clicking an individual node (e.g., + `'Radobod'`) with `{force: true}` to navigate, asserting that a child node + (e.g., `'Chike'`) animates and renders. + - Verifies side-drawer details rendering by asserting that person-specific + data (e.g., `'a random note'`) is visible. ##### 3. Search Test (`tests/search.spec.ts`) + Checks search input, suggestion popups, debouncing, and navigation updates: -* **Setup**: Prepares GEDCOM routing via `setupGedcomRoute` and loads the chart view. -* **Assertions**: - * Targets the search input using the user-facing selector `page.getByPlaceholder('Search for people')`. - * Fills in search queries (e.g., `'chik'`), verifies the autocomplete results panel (`.results`) debounces and renders the target suggestion (`'Chike'`). - * Triggers selection by pressing `'Enter'` and asserts that the main chart view successfully shifts focus and renders the target person's node. + +- **Setup**: Prepares GEDCOM routing via `setupGedcomRoute` and loads the chart + view. +- **Assertions**: + - Targets the search input using the user-facing selector + `page.getByPlaceholder('Search for people')`. + - Fills in search queries (e.g., `'chik'`), verifies the autocomplete results + panel (`.results`) debounces and renders the target suggestion (`'Chike'`). + - Triggers selection by pressing `'Enter'` and asserts that the main chart + view successfully shifts focus and renders the target person's node. ##### 4. WebMCP Test (`tests/webmcp.spec.ts`) -Bridges out-of-process serialization, checks tool registrations, and asserts detail updates: -* **Setup**: Configures GEDCOM intercepts. Prior to navigation, it injects an early script (`page.addInitScript`) that mocks `navigator.modelContext.registerTool` and `navigator.modelContext.unregisterTool` APIs, populating tool metadata in a global `window.__registeredTools` list. -* **Assertions**: - * Verifies all core WebMCP tools (`get_selected_person`, `search_indi`, `inspect_indi`, `focus_indi`, `find_relationship_path`, `get_ancestors`, `get_descendants`) register correctly. Uses a polling assertion (`page.waitForFunction`) to safely wait for the React hook mounting cycle. - * Tests interactive integration by evaluating a script in the browser context that calls the registered `focus_indi` tool callback with target identifier (`{id: 'I21'}`), then asserts that the UI automatically updates the viewer focus to `'Chike'`. + +Bridges out-of-process serialization, checks tool registrations, and asserts +detail updates: + +- **Setup**: Configures GEDCOM intercepts. Prior to navigation, it injects an + early script (`page.addInitScript`) that mocks + `navigator.modelContext.registerTool` and + `navigator.modelContext.unregisterTool` APIs, populating tool metadata in a + global `window.__registeredTools` list. +- **Assertions**: + - Verifies all core WebMCP tools (`get_selected_person`, `search_indi`, + `inspect_indi`, `focus_indi`, `find_relationship_path`, `get_ancestors`, + `get_descendants`) register correctly. Uses a polling assertion + (`page.waitForFunction`) to safely wait for the React hook mounting cycle. + - Tests interactive integration by evaluating a script in the browser context + that calls the registered `focus_indi` tool callback with target identifier + (`{id: 'I21'}`), then asserts that the UI automatically updates the viewer + focus to `'Chike'`. ##### 5. Embedded Test (`tests/embedded.spec.ts`) -Serves local wrapper page template virtually and handles complete bidirectional postMessage handshake: -* **Setup**: Prepares standard GEDCOM routing. Reads the physical `embedded_frame.html` template from disk and registers a route interceptor serving it dynamically at `/test-embedded-frame.html` so both frames stay on the same origin. -* **Assertions**: - * Navigates to `/test-embedded-frame.html`. - * Targets the inner iframe using `page.frameLocator('#topolaFrame')`. - * Verifies that the bidirectional message passing functions correctly by asserting that the inner iframe successfully receives and renders the root family chart nodes (asserting the presence of `'Bonifacy'`). + +Serves local wrapper page template virtually and handles complete bidirectional +postMessage handshake: + +- **Setup**: Prepares standard GEDCOM routing. Reads the physical + `embedded_frame.html` template from disk and registers a route interceptor + serving it dynamically at `/test-embedded-frame.html` so both frames stay on + the same origin. +- **Assertions**: + - Navigates to `/test-embedded-frame.html`. + - Targets the inner iframe using `page.frameLocator('#topolaFrame')`. + - Verifies that the bidirectional message passing functions correctly by + asserting that the inner iframe successfully receives and renders the root + family chart nodes (asserting the presence of `'Bonifacy'`). #### Step 5: CI Pipeline Alignment -Modify the GitHub Actions YAML scripts in `.github/workflows/` to replace all legacy Cypress execution steps. E2E testing runs on all Node environments in the pipeline matrix for comprehensive validation. +Modify the GitHub Actions YAML scripts in `.github/workflows/` to replace all +legacy Cypress execution steps. E2E testing runs on all Node environments in the +pipeline matrix for comprehensive validation. ##### 1. Key Steps for `node.js.yml` -* **Prettier & Lint Checks**: Run formatting checks across both the application and the new tests using `npx prettier --check "{src,tests}/**/*.{ts,tsx,json}"` and run `npm run lint`. -* **Build & Test**: Run standard `npm run build` and unit tests via `npm test`. -* **E2E Spec Typecheck**: Run `npx tsc -p tests/tsconfig.json --noEmit` to guarantee complete E2E spec compilation/type correctness. -* **Playwright Browser Caching**: Extract the current Playwright version and cache the browser binaries (`~/.cache/ms-playwright`) dynamically using `actions/cache@v4` to prevent redundant downloads. -* **Execution**: Install necessary OS libraries using `npx playwright install-deps chromium`, install the browser binary, and run the test runner with `npm run test:e2e`. -* **Artifact Storage**: Archive the resulting `playwright-report/` directory on failure or completion using `actions/upload-artifact@v4` with a 30-day retention period. + +- **Prettier & Lint Checks**: Run formatting checks across both the application + and the new tests using + `npx prettier --check "{src,tests}/**/*.{ts,tsx,json}"` and run + `npm run lint`. +- **Build & Test**: Run standard `npm run build` and unit tests via `npm test`. +- **E2E Spec Typecheck**: Run `npx tsc -p tests/tsconfig.json --noEmit` to + guarantee complete E2E spec compilation/type correctness. +- **Playwright Browser Caching**: Extract the current Playwright version and + cache the browser binaries (`~/.cache/ms-playwright`) dynamically using + `actions/cache@v4` to prevent redundant downloads. +- **Execution**: Install necessary OS libraries using + `npx playwright install-deps chromium`, install the browser binary, and run + the test runner with `npm run test:e2e`. +- **Artifact Storage**: Archive the resulting `playwright-report/` directory on + failure or completion using `actions/upload-artifact@v4` with a 30-day + retention period. ##### 2. Key Steps for `deploy-gh-pages.yml` & `deploy-wikitree-apps.yml` -* No testing steps are run within the deploy workflows. They are fully streamlined to execute checkouts, build production assets, and deploy immediately, leaving gating assertions to the primary merge-to-master CI loop (`node.js.yml`). + +- No testing steps are run within the deploy workflows. They are fully + streamlined to execute checkouts, build production assets, and deploy + immediately, leaving gating assertions to the primary merge-to-master CI loop + (`node.js.yml`). ## 5. Future Considerations ### A. Visual Regression (Screenshot) Testing -While the initial migration focuses on full parity with standard Cypress text-assertion specs, Topola Viewer is a highly visual, SVG-driven charting engine. In a future iteration, we should introduce Playwright visual snapshot/regression testing using `expect(page.locator('#content svg')).toHaveScreenshot('default-chart.png')`. This will automatically catch layout regressions, overlaps, and styled box positioning issues that DOM text assertions cannot detect. + +While the initial migration focuses on full parity with standard Cypress +text-assertion specs, Topola Viewer is a highly visual, SVG-driven charting +engine. In a future iteration, we should introduce Playwright visual +snapshot/regression testing using +`expect(page.locator('#content svg')).toHaveScreenshot('default-chart.png')`. +This will automatically catch layout regressions, overlaps, and styled box +positioning issues that DOM text assertions cannot detect. ### B. File Upload Flow Testing -To ensure users can reliably upload their family trees locally, a future iteration should introduce E2E tests for local file ingestion: -* **Single File Upload (.ged)**: Simulating selection and upload of `.ged` text files using Playwright's `setInputFiles()` or `filechooser` events, verifying that the application correctly transitions to the visual chart view. -* **Multi-File / Image Upload**: Verifying that uploading a `.ged` file along with associated `.jpg` or `.png` image assets resolves and binds photos to individuals in the chart without errors. -* **Fixture Management**: This will require checking in small, 1x1 pixel dummy images to act as local mock visual resources. + +To ensure users can reliably upload their family trees locally, a future +iteration should introduce E2E tests for local file ingestion: + +- **Single File Upload (.ged)**: Simulating selection and upload of `.ged` text + files using Playwright's `setInputFiles()` or `filechooser` events, verifying + that the application correctly transitions to the visual chart view. +- **Multi-File / Image Upload**: Verifying that uploading a `.ged` file along + with associated `.jpg` or `.png` image assets resolves and binds photos to + individuals in the chart without errors. +- **Fixture Management**: This will require checking in small, 1x1 pixel dummy + images to act as local mock visual resources. ### C. CI/CD Pipeline Optimization (Shared E2E Gating Job) -Currently, the manual deployment workflow `deploy-everywhere.yml` triggers both `deploy-gh-pages.yml` and `deploy-wikitree-apps.yml` in parallel, which duplicates E2E test execution and results in running the slow Playwright test runner twice simultaneously. A future optimization should restructure the workflows to execute the E2E validation once as a gating pre-deploy job (`needs: e2e-validation`), or rely strictly on the main merge-to-master CI pipeline validation, ensuring manual deployments are fast, clean, and lightweight. + +Currently, the manual deployment workflow `deploy-everywhere.yml` triggers both +`deploy-gh-pages.yml` and `deploy-wikitree-apps.yml` in parallel, which +duplicates E2E test execution and results in running the slow Playwright test +runner twice simultaneously. A future optimization should restructure the +workflows to execute the E2E validation once as a gating pre-deploy job +(`needs: e2e-validation`), or rely strictly on the main merge-to-master CI +pipeline validation, ensuring manual deployments are fast, clean, and +lightweight. diff --git a/docs/PROBERS_DESIGN.md b/docs/PROBERS_DESIGN.md index 9102741..02ff9b0 100644 --- a/docs/PROBERS_DESIGN.md +++ b/docs/PROBERS_DESIGN.md @@ -3,34 +3,33 @@ ## 1. Problem Statement Topola Viewer is deployed to two environments — GitHub Pages and -apps.wikitree.com — and depends on external services outside of our control: -the WikiTree API and a third-party CORS proxy (`topolaproxy.bieda.it`). Any of -these moving parts can break independently of our code: the WikiTree API can -change its response schema or rate-limit requests, the CORS proxy can go down -or change its URL scheme, and a deployment can silently introduce a routing or -build issue that only manifests in production. Our existing test suite is -hermetic — it mocks all network calls — so it verifies code correctness but -cannot detect when the live, deployed system stops working end-to-end. We need -lightweight smoke tests ("probers") that run against the live deployed URLs to -catch real-world breakage, both immediately after each deployment and on a -daily schedule. +apps.wikitree.com — and depends on external services outside of our control: the +WikiTree API and a third-party CORS proxy (`topolaproxy.bieda.it`). Any of these +moving parts can break independently of our code: the WikiTree API can change +its response schema or rate-limit requests, the CORS proxy can go down or change +its URL scheme, and a deployment can silently introduce a routing or build issue +that only manifests in production. Our existing test suite is hermetic — it +mocks all network calls — so it verifies code correctness but cannot detect when +the live, deployed system stops working end-to-end. We need lightweight smoke +tests ("probers") that run against the live deployed URLs to catch real-world +breakage, both immediately after each deployment and on a daily schedule. ## 2. The Technical Plan The prober system consists of four independent smoke tests, each targeting a -specific combination of deployment target and data path. Three of the four -tests launch a real browser and navigate to a live deployed URL. The fourth -pulls the Docker image published to GHCR, runs it locally, and -verifies that the containerized application starts and renders data. All tests -verify that the chart renders, the side panel shows the expected person's name, -and no error message is displayed. +specific combination of deployment target and data path. Three of the four tests +launch a real browser and navigate to a live deployed URL. The fourth pulls the +Docker image published to GHCR, runs it locally, and verifies that the +containerized application starts and renders data. All tests verify that the +chart renders, the side panel shows the expected person's name, and no error +message is displayed. The four probers are: 1. **WikiTree direct API prober** — Loads a known WikiTree profile - (`Skłodowska-2`) from the app deployed on `apps.wikitree.com`. This - exercises the direct WikiTree API path (no CORS proxy) and confirms the - WikiTree deployment is healthy. + (`Skłodowska-2`) from the app deployed on `apps.wikitree.com`. This exercises + the direct WikiTree API path (no CORS proxy) and confirms the WikiTree + deployment is healthy. 2. **GitHub Pages GEDCOM prober** — Loads a GEDCOM file from a raw GitHub URL through the app on `pewu.github.io`. Because the app is not on the @@ -44,12 +43,12 @@ The four probers are: default. This confirms the CORS proxy is reachable from the WikiTree deployment. -4. **Docker container prober** — Pulls the Docker image published to GHCR - by `deploy-docker.yml`, runs it locally with the test GEDCOM file mounted - via `STATIC_URL`, and verifies that the application renders the chart. - This exercises the Docker build path (multi-stage `Dockerfile`, Caddy - server configuration, static URL template injection) and confirms the - published container image starts and serves data correctly. +4. **Docker container prober** — Pulls the Docker image published to GHCR by + `deploy-docker.yml`, runs it locally with the test GEDCOM file mounted via + `STATIC_URL`, and verifies that the application renders the chart. This + exercises the Docker build path (multi-stage `Dockerfile`, Caddy server + configuration, static URL template injection) and confirms the published + container image starts and serves data correctly. Each prober is a standalone GitHub Actions workflow that can be triggered in three ways: automatically after a deploy finishes, on a daily schedule, or @@ -110,21 +109,21 @@ flowchart TD Each prober is a small Playwright test spec. Three specs run against live deployed URLs; the Docker prober spec runs against a local Docker container -started by the workflow. The specs live in a separate `tests/probers/` -directory with their own Playwright configuration -(`playwright.prober.config.ts`) so they are completely isolated from the -existing hermetic test suite. Note: because the existing `playwright.config.ts` -uses `testDir: './tests'` and Playwright searches recursively, the e2e project -in the existing config must add `testIgnore: ['*_visual.spec.ts', 'probers/**']` -to prevent prober specs from being picked up by the regular CI test run -(`npm run test:e2e` or `npm run test:visual`). The prober config does not start -a local dev server — each spec navigates to a full absolute URL (or -`localhost:8080` for the Docker prober). A successful prober means a user can -load the app and see data; a failure means something in the chain is broken -and triggers an email notification. Note: GitHub Actions only sends email -notifications if the user has explicitly enabled email notifications in their -GitHub notification settings (Settings → Notifications → Email). If email -notifications are disabled, failures are only visible in the Actions UI. +started by the workflow. The specs live in a separate `tests/probers/` directory +with their own Playwright configuration (`playwright.prober.config.ts`) so they +are completely isolated from the existing hermetic test suite. Note: because the +existing `playwright.config.ts` uses `testDir: './tests'` and Playwright +searches recursively, the e2e project in the existing config must add +`testIgnore: ['*_visual.spec.ts', 'probers/**']` to prevent prober specs from +being picked up by the regular CI test run (`npm run test:e2e` or +`npm run test:visual`). The prober config does not start a local dev server — +each spec navigates to a full absolute URL (or `localhost:8080` for the Docker +prober). A successful prober means a user can load the app and see data; a +failure means something in the chain is broken and triggers an email +notification. Note: GitHub Actions only sends email notifications if the user +has explicitly enabled email notifications in their GitHub notification settings +(Settings → Notifications → Email). If email notifications are disabled, +failures are only visible in the Actions UI. ## 3. Alternatives Considered & Rejected @@ -133,143 +132,142 @@ explicitly rejected. They are documented here to prevent future re-litigation and to serve as guardrails against scope creep. ### Alternative A: Unit-level API integration tests against the live + WikiTree API -* **Considered:** Writing tests that call the raw `wikitree-js` library +- **Considered:** Writing tests that call the raw `wikitree-js` library functions directly against the live WikiTree API, verifying response schemas and field presence. -* **Why Rejected:** This tests the `wikitree-js` dependency, not our code. Our - existing Jest unit tests already cover our transformation logic using - mocked API responses. The goal of probers is to verify the full - end-to-end chain — browser, deployed app, network, API, proxy — not to - re-verify API response shapes. Adding a separate layer of API-level - integration tests would duplicate coverage without catching deployment or - proxy issues. +- **Why Rejected:** This tests the `wikitree-js` dependency, not our code. Our + existing Jest unit tests already cover our transformation logic using mocked + API responses. The goal of probers is to verify the full end-to-end chain — + browser, deployed app, network, API, proxy — not to re-verify API response + shapes. Adding a separate layer of API-level integration tests would duplicate + coverage without catching deployment or proxy issues. ### Alternative B: Testing the CORS proxy on apps.wikitree.com via the + WikiTree data path -* **Considered:** Forcing the WikiTree API calls through the CORS proxy when - the app is deployed on `apps.wikitree.com`, to test the proxy from that +- **Considered:** Forcing the WikiTree API calls through the CORS proxy when the + app is deployed on `apps.wikitree.com`, to test the proxy from that domain. +- **Why Rejected:** The app hardcodes `handleCors` based on hostname — on + `apps.wikitree.com`, WikiTree API calls always go direct (no proxy). There is + no URL parameter to override this for the WikiTree data source. Forcing the + proxy path would require a code change for test-only purposes, which is not + justified. Instead, the CORS proxy is tested on `apps.wikitree.com` through + the GEDCOM-from-URL path, which uses the proxy by default regardless of domain. -* **Why Rejected:** The app hardcodes `handleCors` based on hostname — on - `apps.wikitree.com`, WikiTree API calls always go direct (no proxy). There - is no URL parameter to override this for the WikiTree data source. Forcing - the proxy path would require a code change for test-only purposes, which is - not justified. Instead, the CORS proxy is tested on `apps.wikitree.com` - through the GEDCOM-from-URL path, which uses the proxy by default - regardless of domain. ### Alternative C: Monolithic prober workflow with multiple jobs -* **Considered:** A single `prober.yml` workflow containing all four prober +- **Considered:** A single `prober.yml` workflow containing all four prober tests as separate jobs within it. -* **Why Rejected:** Separate workflow files give finer-grained control in the +- **Why Rejected:** Separate workflow files give finer-grained control in the GitHub Actions UI — each prober can be triggered, re-run, or inspected independently. They also allow each prober to declare a targeted `needs` - dependency on only the relevant deploy job (e.g., the WikiTree prober - depends on `deploy-wikitree-apps`, not `deploy-gh-pages`). A monolithic - workflow would couple all probers to the same trigger and make partial - failures harder to manage. + dependency on only the relevant deploy job (e.g., the WikiTree prober depends + on `deploy-wikitree-apps`, not `deploy-gh-pages`). A monolithic workflow would + couple all probers to the same trigger and make partial failures harder to + manage. ### Alternative D: Probers that depend on all deploys finishing -* **Considered:** Making all four probers wait for all of `deploy-gh-pages`, - `deploy-wikitree-apps`, and `deploy-docker` to complete before running - any of them. -* **Why Rejected:** This unnecessarily delays probers whose target has - already been deployed. The WikiTree probers only need the WikiTree deploy - to finish; the GitHub Pages prober only needs the GitHub Pages deploy. - Coupling them to all deploys adds latency without benefit, and means a - failure in one deploy would block probers for the other. +- **Considered:** Making all four probers wait for all of `deploy-gh-pages`, + `deploy-wikitree-apps`, and `deploy-docker` to complete before running any of + them. +- **Why Rejected:** This unnecessarily delays probers whose target has already + been deployed. The WikiTree probers only need the WikiTree deploy to finish; + the GitHub Pages prober only needs the GitHub Pages deploy. Coupling them to + all deploys adds latency without benefit, and means a failure in one deploy + would block probers for the other. ### Alternative E: Unconditional sleep before every prober run -* **Considered:** Always waiting 3 minutes at the start of every prober run, +- **Considered:** Always waiting 3 minutes at the start of every prober run, regardless of trigger source. -* **Why Rejected:** The sleep is only necessary after a deploy, to allow - GitHub Pages or WikiTree to propagate the new version. For daily scheduled - runs and manual triggers, there is no recent deploy to wait for, so the - sleep wastes 3 minutes. Instead, a `wait_for_propagation` input flag is - passed as `true` only when the prober is invoked from the deploy workflow. +- **Why Rejected:** The sleep is only necessary after a deploy, to allow GitHub + Pages or WikiTree to propagate the new version. For daily scheduled runs and + manual triggers, there is no recent deploy to wait for, so the sleep wastes 3 + minutes. Instead, a `wait_for_propagation` input flag is passed as `true` only + when the prober is invoked from the deploy workflow. ### Alternative F: Correctness assertions against specific WikiTree profile + data -* **Considered:** Asserting detailed data fields (e.g., specific birth dates, - parent IDs, spouse counts) from the `Skłodowska-2` WikiTree profile to - verify data correctness. -* **Why Rejected:** Probers are smoke tests — their job is to verify "does - the pipe work?", not "is the data correct?". Data correctness is already - verified by the hermetic test suite with controlled fixtures. Coupling - probers to specific WikiTree profile data creates fragility: if anyone - edits the WikiTree profile, the prober would break even though the system - is healthy. Probers assert only that the expected person's name appears in - the chart and side panel, and that no error is displayed. +- **Considered:** Asserting detailed data fields (e.g., specific birth dates, + parent IDs, spouse counts) from the `Skłodowska-2` WikiTree profile to verify + data correctness. +- **Why Rejected:** Probers are smoke tests — their job is to verify "does the + pipe work?", not "is the data correct?". Data correctness is already verified + by the hermetic test suite with controlled fixtures. Coupling probers to + specific WikiTree profile data creates fragility: if anyone edits the WikiTree + profile, the prober would break even though the system is healthy. Probers + assert only that the expected person's name appears in the chart and side + panel, and that no error is displayed. ## 4. Detailed Implementation Plan -This section enumerates every file that will be created or modified, in -the order they should be implemented, along with the rationale for each -change. The implementation is divided into five steps. +This section enumerates every file that will be created or modified, in the +order they should be implemented, along with the rationale for each change. The +implementation is divided into five steps. ### Step 1: Prober Playwright configuration **Create:** `playwright.prober.config.ts` -A separate Playwright configuration file dedicated to prober tests. This -file is distinct from the existing `playwright.config.ts` and serves a -different purpose: it does not start a local dev server, does not define -visual regression projects, and runs only against live deployed URLs. +A separate Playwright configuration file dedicated to prober tests. This file is +distinct from the existing `playwright.config.ts` and serves a different +purpose: it does not start a local dev server, does not define visual regression +projects, and runs only against live deployed URLs. Rationale for key configuration decisions: -* **No `webServer`** — The existing config starts a Vite dev/preview server - on `localhost:3000`. The prober config does not use Playwright's - `webServer` feature. Live-URL probers navigate to full absolute URLs; - the Docker prober's workflow starts the container externally (via - `docker run`) before the test runs, so Playwright connects to - `localhost:8080` without a `webServer` definition. -* **`testDir: './tests/probers'`** — Prober specs are isolated in their own - directory. Additionally, the existing `playwright.config.ts` e2e project - must add `testIgnore: ['*_visual.spec.ts', 'probers/**']` to prevent - prober specs from being discovered by the regular CI test run, since - Playwright searches `testDir` recursively. -* **`fullyParallel: false`** — Tests run sequentially to avoid hammering the - live WikiTree API and CORS proxy with concurrent requests, which could - trigger rate-limiting. -* **`retries: 2`** — The WikiTree API and CORS proxy can have transient +- **No `webServer`** — The existing config starts a Vite dev/preview server on + `localhost:3000`. The prober config does not use Playwright's `webServer` + feature. Live-URL probers navigate to full absolute URLs; the Docker prober's + workflow starts the container externally (via `docker run`) before the test + runs, so Playwright connects to `localhost:8080` without a `webServer` + definition. +- **`testDir: './tests/probers'`** — Prober specs are isolated in their own + directory. Additionally, the existing `playwright.config.ts` e2e project must + add `testIgnore: ['*_visual.spec.ts', 'probers/**']` to prevent prober specs + from being discovered by the regular CI test run, since Playwright searches + `testDir` recursively. +- **`fullyParallel: false`** — Tests run sequentially to avoid hammering the + live WikiTree API and CORS proxy with concurrent requests, which could trigger + rate-limiting. +- **`retries: 2`** — The WikiTree API and CORS proxy can have transient failures. Two retries (same as the existing CI config) provides a buffer against flakiness without masking persistent failures. -* **`timeout: 120000`** — The WikiTree API prober makes multiple sequential - API calls (ancestors, descendants, relatives) that can take over 30 - seconds under load. The default 30s timeout is too short for live API - probers; 120 seconds provides adequate headroom. -* **`reporter: [['html', {open: 'never'}], ['list']]`** — Generates an HTML - report for upload as a workflow artifact, plus list output for console - logs. Without this, no HTML report is produced and there is nothing to - upload. -* **`forbidOnly: true`** — Since probers always run in CI, `forbidOnly` - should be set to `true` to prevent `test.only` from accidentally blocking - all other prober specs. (The existing config uses `forbidOnly: - !!process.env.CI`, which achieves the same effect when `CI` is set, but - probers should enforce this unconditionally.) -* **Single project named `prober` using `devices['Desktop Chrome']`** — No - need for separate e2e/visual projects. All prober specs are smoke tests. - The project must explicitly use `devices['Desktop Chrome']` to ensure a - desktop viewport, because the side panel visibility depends on - `window.matchMedia('(max-width: 767px)')` (see the `getShowSidePanel` function in - `src/util/url_args.ts`). - Without an explicit device, Playwright's default viewport may be too - narrow, causing the side panel to be hidden and the `.details` assertion - to fail. -* **No `expect.toHaveScreenshot`** — Probers do not do visual regression +- **`timeout: 120000`** — The WikiTree API prober makes multiple sequential API + calls (ancestors, descendants, relatives) that can take over 30 seconds under + load. The default 30s timeout is too short for live API probers; 120 seconds + provides adequate headroom. +- **`reporter: [['html', {open: 'never'}], ['list']]`** — Generates an HTML + report for upload as a workflow artifact, plus list output for console logs. + Without this, no HTML report is produced and there is nothing to upload. +- **`forbidOnly: true`** — Since probers always run in CI, `forbidOnly` should + be set to `true` to prevent `test.only` from accidentally blocking all other + prober specs. (The existing config uses `forbidOnly: !!process.env.CI`, which + achieves the same effect when `CI` is set, but probers should enforce this + unconditionally.) +- **Single project named `prober` using `devices['Desktop Chrome']`** — No need + for separate e2e/visual projects. All prober specs are smoke tests. The + project must explicitly use `devices['Desktop Chrome']` to ensure a desktop + viewport, because the side panel visibility depends on + `window.matchMedia('(max-width: 767px)')` (see the `getShowSidePanel` function + in `src/util/url_args.ts`). Without an explicit device, Playwright's default + viewport may be too narrow, causing the side panel to be hidden and the + `.details` assertion to fail. +- **No `expect.toHaveScreenshot`** — Probers do not do visual regression testing; that is handled by the existing visual test project. -* **`trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`, - `video: 'on-first-retry'`** — For live-URL probers where failures are hard - to reproduce, trace files, failure screenshots, and retry videos are - essential for debugging. -* **`locale: 'en-US'`** — Forces consistent rendering and translation keys, +- **`trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`, + `video: 'on-first-retry'`** — For live-URL probers where failures are hard to + reproduce, trace files, failure screenshots, and retry videos are essential + for debugging. +- **`locale: 'en-US'`** — Forces consistent rendering and translation keys, matching the existing CI config. Without this, the app renders in the CI runner's default locale, which is non-deterministic. @@ -280,65 +278,65 @@ targets a different URL and asserts a different expected name. **Create:** `tests/probers/wikitree.spec.ts` -* **Target URL:** +- **Target URL:** `https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&indi=Sk%C5%82odowska-2` (URL-encoded `Skłodowska-2` to avoid encoding ambiguity with the non-ASCII character `ł` in source code). -* **Expected name:** `Skłodowska` (from the WikiTree profile - `Skłodowska-2` — Marie Skłodowska-Curie). The chart displays - `LastNameAtBirth`, which is `Skłodowska` for this profile. -* **What it exercises:** WikiTree direct API (no CORS proxy), WikiTree +- **Expected name:** `Skłodowska` (from the WikiTree profile `Skłodowska-2` — + Marie Skłodowska-Curie). The chart displays `LastNameAtBirth`, which is + `Skłodowska` for this profile. +- **What it exercises:** WikiTree direct API (no CORS proxy), WikiTree deployment. -* **Note:** Does not use `standalone=true` in the URL. The app defaults to +- **Note:** Does not use `standalone=true` in the URL. The app defaults to standalone mode when not embedded and no static URL is set (see `src/util/url_args.ts`). **Create:** `tests/probers/gh-pages-gedcom.spec.ts` -* **Target URL:** +- **Target URL:** `https://pewu.github.io/topola-viewer/#/view?url=https://raw.githubusercontent.com/PeWu/topola-viewer/master/src/datasource/testdata/test.ged&indi=I1` -* **Expected name:** `Bonifacy` (individual `@I1@` in `test.ged`, line 16: +- **Expected name:** `Bonifacy` (individual `@I1@` in `test.ged`, line 16: `1 NAME Bonifacy /Gibbs/`). -* **What it exercises:** GitHub Pages deployment, CORS proxy - (`topolaproxy.bieda.it`), GEDCOM-from-URL loading. The app uses the CORS - proxy by default for GEDCOM URLs (`handleCors` defaults to `true` — see +- **What it exercises:** GitHub Pages deployment, CORS proxy + (`topolaproxy.bieda.it`), GEDCOM-from-URL loading. The app uses the CORS proxy + by default for GEDCOM URLs (`handleCors` defaults to `true` — see `src/util/url_args.ts:156`). **Create:** `tests/probers/wikitree-cors-gedcom.spec.ts` -* **Target URL:** +- **Target URL:** `https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?url=https://raw.githubusercontent.com/PeWu/topola-viewer/master/src/datasource/testdata/test.ged&indi=I1` -* **Expected name:** `Bonifacy` (same as above). -* **What it exercises:** WikiTree deployment, CORS proxy from the WikiTree - domain. Even on `apps.wikitree.com`, GEDCOM-from-URL uses the CORS proxy - by default (the `handleCors` hostname check in `src/datasource/wikitree_api.ts` +- **Expected name:** `Bonifacy` (same as above). +- **What it exercises:** WikiTree deployment, CORS proxy from the WikiTree + domain. Even on `apps.wikitree.com`, GEDCOM-from-URL uses the CORS proxy by + default (the `handleCors` hostname check in `src/datasource/wikitree_api.ts` only affects WikiTree API calls, not GEDCOM URL fetches in `src/datasource/load_data.ts`). Note: probers do not block Google Analytics - scripts, so live-URL prober runs generate real analytics events on each - run. This is intentional — the prober tests the unmodified deployed app, - and blocking analytics would not reflect the real user experience. + scripts, so live-URL prober runs generate real analytics events on each run. + This is intentional — the prober tests the unmodified deployed app, and + blocking analytics would not reflect the real user experience. **Create:** `tests/probers/docker.spec.ts` -* **Target URL:** `http://localhost:8080/` (local Docker container). -* **Expected name:** `Bonifacy` (same GEDCOM test file, mounted into the +- **Target URL:** `http://localhost:8080/` (local Docker container). +- **Expected name:** `Bonifacy` (same GEDCOM test file, mounted into the container via `STATIC_URL=test.ged`). -* **What it exercises:** Published Docker image from GHCR (multi-stage - `Dockerfile` build output, Caddy server configuration, static URL - template injection (`{{ env "STATIC_URL" }}` in `index.html`)), and app - rendering with a pre-loaded GEDCOM. -* **Note:** The workflow pulls the Docker image published to GHCR +- **What it exercises:** Published Docker image from GHCR (multi-stage + `Dockerfile` build output, Caddy server configuration, static URL template + injection (`{{ env "STATIC_URL" }}` in `index.html`)), and app rendering with + a pre-loaded GEDCOM. +- **Note:** The workflow pulls the Docker image published to GHCR (`ghcr.io/pewu/topola-viewer:latest`), runs it with `docker run -p 8080:8080 -e STATIC_URL=test.ged`, mounts - `src/datasource/testdata/test.ged` into the container, and points - Playwright at `localhost:8080`. The app loads in non-standalone mode - (because `staticUrl` is set) and navigates directly to the chart view - (see `app.tsx` routing logic). The Docker image does not include Google - credentials (`VITE_GOOGLE_CLIENT_ID`, `VITE_GOOGLE_API_KEY`), so the - Google Drive integration is non-functional in the containerized app. - This is acceptable for the prober, which only tests chart rendering. - Note: the Docker prober tests the image published to GHCR by - `deploy-docker.yml`, ensuring the published artifact is functional. + `src/datasource/testdata/test.ged` into the container, and points Playwright + at `localhost:8080`. The app loads in non-standalone mode (because `staticUrl` + is set) and navigates directly to the chart view (see `app.tsx` routing + logic). The Docker image does not include Google credentials + (`VITE_GOOGLE_CLIENT_ID`, `VITE_GOOGLE_API_KEY`), so the Google Drive + integration is non-functional in the containerized app. This is acceptable for + the prober, which only tests chart rendering. Note: the Docker prober tests + the image published to GHCR by `deploy-docker.yml`, ensuring the published + artifact is functional. **Shared test structure** (in `tests/probers/helpers.ts`, called by each spec): @@ -368,40 +366,39 @@ Playwright's `getByTestId` searches the entire document, so it matches the Selectors are derived from the source code: -* `#content` — main container, visible when chart state is `SHOWING_CHART` - (see the `renderMainArea` function in `src/pages/view_page.tsx`). -* `#chart` — SVG group inside the chart (see `src/chart.tsx`). -* `div.details` — side panel Details tab content (see the `Details` component in +- `#content` — main container, visible when chart state is `SHOWING_CHART` (see + the `renderMainArea` function in `src/pages/view_page.tsx`). +- `#chart` — SVG group inside the chart (see `src/chart.tsx`). +- `div.details` — side panel Details tab content (see the `Details` component in `src/sidepanel/details/details.tsx`). -* `.ui.error.message` — fatal error replacing the chart (see - `src/components/error_display.tsx`, rendered when state is `ERROR`). The `ui` and - `message` classes are added by Semantic UI React's `` - component; the `error` class comes from the custom `className="error"` - prop in `ErrorMessage`. The resulting DOM element is - `
`, so the selector - `.ui.error.message` matches it. -* `.ui.errorPopup.message` — dismissable popup error (see +- `.ui.error.message` — fatal error replacing the chart (see + `src/components/error_display.tsx`, rendered when state is `ERROR`). The `ui` + and `message` classes are added by Semantic UI React's `` component; + the `error` class comes from the custom `className="error"` prop in + `ErrorMessage`. The resulting DOM element is + `
`, so the selector `.ui.error.message` + matches it. +- `.ui.errorPopup.message` — dismissable popup error (see `src/components/error_display.tsx`). As above, `ui` and `message` come from - Semantic UI React's ``, and `errorPopup` comes from the - custom `className="errorPopup"` prop. Note: `ErrorPopup` uses Semantic - UI React's ``, which renders its content at `document.body` - level, not inside `#content` in the DOM. When the popup is closed - (`open={false}`), the Portal renders nothing, so this assertion - verifies absence rather than visibility. + Semantic UI React's ``, and `errorPopup` comes from the custom + `className="errorPopup"` prop. Note: `ErrorPopup` uses Semantic UI React's + ``, which renders its content at `document.body` level, not inside + `#content` in the DOM. When the popup is closed (`open={false}`), the Portal + renders nothing, so this assertion verifies absence rather than visibility. The side panel is expanded by default on desktop viewports (the prober project uses `devices['Desktop Chrome']`). The `getShowSidePanel` function in -`src/util/url_args.ts` returns `true` on non-mobile screens, so the `div.details` -container is visible without any URL parameters. +`src/util/url_args.ts` returns `true` on non-mobile screens, so the +`div.details` container is visible without any URL parameters. -All prober selectors use `data-testid` attributes (e.g., `data-testid="content"`, -`data-testid="chart"`, `data-testid="details"`, `data-testid="error-message"`, -`data-testid="error-popup"`) rather than CSS classes or element IDs. This makes -selectors resilient to CSS class refactors and Semantic UI React internal -changes. The `data-testid` attributes are added to the source components -alongside existing IDs and classes. A shared helper (`tests/probers/helpers.ts`) -encapsulates the prober flow and selector logic, eliminating duplication across -spec files. +All prober selectors use `data-testid` attributes (e.g., +`data-testid="content"`, `data-testid="chart"`, `data-testid="details"`, +`data-testid="error-message"`, `data-testid="error-popup"`) rather than CSS +classes or element IDs. This makes selectors resilient to CSS class refactors +and Semantic UI React internal changes. The `data-testid` attributes are added +to the source components alongside existing IDs and classes. A shared helper +(`tests/probers/helpers.ts`) encapsulates the prober flow and selector logic, +eliminating duplication across spec files. ### Step 3: Prober GitHub Actions workflows @@ -413,16 +410,15 @@ permissions: actions: write ``` -All prober workflows should use `actions/checkout@v4` (not v2, which is -used by some older deploy workflows). +All prober workflows should use `actions/checkout@v4` (not v2, which is used by +some older deploy workflows). All prober workflows should set `timeout-minutes: 15` on each job to prevent -hanging runs from consuming runner minutes (default GitHub Actions timeout is -6 hours). +hanging runs from consuming runner minutes (default GitHub Actions timeout is 6 +hours). -All prober workflows should define a `concurrency` group to prevent -overlapping runs (e.g., a deploy-triggered run overlapping with a -schedule-triggered run): +All prober workflows should define a `concurrency` group to prevent overlapping +runs (e.g., a deploy-triggered run overlapping with a schedule-triggered run): ```yaml concurrency: @@ -430,43 +426,41 @@ concurrency: cancel-in-progress: false ``` -`cancel-in-progress: false` ensures a deploy-triggered run is not cancelled -by a scheduled run — both complete independently. +`cancel-in-progress: false` ensures a deploy-triggered run is not cancelled by a +scheduled run — both complete independently. -Four reusable workflow files, one per prober. The three live-URL probers -are identical in structure — only the name and artifact name differ. The -Docker prober has a different structure (it builds and runs the container -before testing). +Four reusable workflow files, one per prober. The three live-URL probers are +identical in structure — only the name and artifact name differ. The Docker +prober has a different structure (it builds and runs the container before +testing). **Create:** `.github/workflows/prober-wikitree.yml` -* **Triggers:** `workflow_call` (with `wait_for_propagation` input), - `workflow_dispatch` (with `wait_for_propagation` input), `schedule` - (daily at `0 5 * * *` UTC = ~6:00/7:00 CET). -* **Depends on (when called from deploy):** `deploy-wikitree-apps.yml` - only. -* **Artifact name:** `prober-report-wikitree`. +- **Triggers:** `workflow_call` (with `wait_for_propagation` input), + `workflow_dispatch` (with `wait_for_propagation` input), `schedule` (daily at + `0 5 * * *` UTC = ~6:00/7:00 CET). +- **Depends on (when called from deploy):** `deploy-wikitree-apps.yml` only. +- **Artifact name:** `prober-report-wikitree`. **Create:** `.github/workflows/prober-gh-pages.yml` -* Same structure. -* **Depends on (when called from deploy):** `deploy-gh-pages.yml` only. -* **Artifact name:** `prober-report-gh-pages`. +- Same structure. +- **Depends on (when called from deploy):** `deploy-gh-pages.yml` only. +- **Artifact name:** `prober-report-gh-pages`. **Create:** `.github/workflows/prober-wikitree-cors.yml` -* Same structure. -* **Depends on (when called from deploy):** `deploy-wikitree-apps.yml` - only. -* **Artifact name:** `prober-report-wikitree-cors`. +- Same structure. +- **Depends on (when called from deploy):** `deploy-wikitree-apps.yml` only. +- **Artifact name:** `prober-report-wikitree-cors`. **Create:** `.github/workflows/prober-docker.yml` -* **Triggers:** `workflow_call`, `workflow_dispatch`, `schedule` - (daily at `0 5 * * *` UTC). -* **Depends on (when called from deploy):** `deploy-docker.yml` only. -* **Artifact name:** `prober-report-docker`. -* **No `wait_for_propagation` input** — The Docker container is available +- **Triggers:** `workflow_call`, `workflow_dispatch`, `schedule` (daily at + `0 5 * * *` UTC). +- **Depends on (when called from deploy):** `deploy-docker.yml` only. +- **Artifact name:** `prober-report-docker`. +- **No `wait_for_propagation` input** — The Docker container is available immediately after startup; no propagation delay is needed. **Shared workflow structure** (live-URL probers): @@ -497,7 +491,7 @@ before testing). **Docker prober workflow structure** (different from live-URL probers): -``` +```` 1. Checkout repository (actions/checkout@v4). 2. Pull Docker image: docker pull ghcr.io/pewu/topola-viewer:latest (Pull the image published by deploy-docker.yml. This tests the actual @@ -527,27 +521,26 @@ before testing). sleep 1 done curl -sf -o /dev/null http://localhost:8080/ - ``` - The final `curl` ensures the workflow fails with a clear error if - the container never became ready. This prevents a race condition - where the test runs before Caddy is ready to serve requests. This - step runs after Node/Playwright setup so the container doesn't sit - idle during dependency installation. - The Docker spec also includes a guard that checks if localhost:8080 - is reachable before running the prober. If the container is not - running (e.g., when running probers locally without Docker), the - test is skipped with a helpful message instead of failing with a - confusing ECONNREFUSED error. -10. Run: npx playwright test --config=playwright.prober.config.ts docker.spec.ts -11. Upload Playwright HTML report as artifact (if: always()). Set - `retention-days: 30` (same as live-URL probers). -12. Stop and remove container (if: always()): docker stop - topola-prober-${{ github.run_id }} 2>/dev/null || true; docker rm - topola-prober-${{ github.run_id }} 2>/dev/null || true - (The if: always() ensures cleanup runs even on failure. The - 2>/dev/null and trailing true prevent errors if the container was - never started, e.g., pull failed at step 2.) -``` +```` + +The final `curl` ensures the workflow fails with a clear error if the container +never became ready. This prevents a race condition where the test runs before +Caddy is ready to serve requests. This step runs after Node/Playwright setup so +the container doesn't sit idle during dependency installation. The Docker spec +also includes a guard that checks if localhost:8080 is reachable before running +the prober. If the container is not running (e.g., when running probers locally +without Docker), the test is skipped with a helpful message instead of failing +with a confusing ECONNREFUSED error. 10. Run: npx playwright test +--config=playwright.prober.config.ts docker.spec.ts 11. Upload Playwright HTML +report as artifact (if: always()). Set `retention-days: 30` (same as live-URL +probers). 12. Stop and remove container (if: always()): docker stop +topola-prober-${{ github.run_id }} 2>/dev/null || true; docker rm + topola-prober-${{ github.run_id }} +2>/dev/null || true (The if: always() ensures cleanup runs even on failure. The +2>/dev/null and trailing true prevent errors if the container was never started, +e.g., pull failed at step 2.) + +```` **`wait_for_propagation` input flag** (live-URL probers only): @@ -580,7 +573,7 @@ jobs: deploy-docker: uses: ./.github/workflows/deploy-docker.yml secrets: inherit -``` +```` After changes: @@ -622,34 +615,34 @@ jobs: Rationale for dependency mapping: -* `prober-wikitree` needs `deploy-wikitree-apps` — it tests the WikiTree +- `prober-wikitree` needs `deploy-wikitree-apps` — it tests the WikiTree deployment. -* `prober-gh-pages` needs `deploy-gh-pages` — it tests the GitHub Pages +- `prober-gh-pages` needs `deploy-gh-pages` — it tests the GitHub Pages deployment. -* `prober-wikitree-cors` needs `deploy-wikitree-apps` — it tests the - WikiTree deployment (with CORS proxy). -* `prober-docker` needs `deploy-docker` — it tests the Docker image - published to GHCR by `deploy-docker.yml` (Dockerfile, Caddy config, app - startup). It does not pass `wait_for_propagation` because the container is - available immediately after `docker run`. -* If a prober fails, the `deploy-everywhere` workflow is marked as failed - (red X), triggering an email notification (if GitHub email notifications - are enabled — see note in Section 2). -* Note: Individual deploy workflows (`deploy-gh-pages.yml`, +- `prober-wikitree-cors` needs `deploy-wikitree-apps` — it tests the WikiTree + deployment (with CORS proxy). +- `prober-docker` needs `deploy-docker` — it tests the Docker image published to + GHCR by `deploy-docker.yml` (Dockerfile, Caddy config, app startup). It does + not pass `wait_for_propagation` because the container is available immediately + after `docker run`. +- If a prober fails, the `deploy-everywhere` workflow is marked as failed (red + X), triggering an email notification (if GitHub email notifications are + enabled — see note in Section 2). +- Note: Individual deploy workflows (`deploy-gh-pages.yml`, `deploy-wikitree-apps.yml`, `deploy-docker.yml`) also support - `workflow_dispatch`. If a deploy is triggered directly (instead of - through `deploy-everywhere.yml`), no probers run because probers are only - called from `deploy-everywhere.yml`. To ensure probers always run after a - deploy, always trigger deploys through `deploy-everywhere.yml`. + `workflow_dispatch`. If a deploy is triggered directly (instead of through + `deploy-everywhere.yml`), no probers run because probers are only called from + `deploy-everywhere.yml`. To ensure probers always run after a deploy, always + trigger deploys through `deploy-everywhere.yml`. ### Step 5: Update supporting files **Modify:** `playwright.config.ts` Add `testIgnore: ['*_visual.spec.ts', 'probers/**']` to the e2e project to -prevent prober specs in `tests/probers/` from being discovered by the regular -CI e2e test run. Without this, `npm run test:e2e` would try to execute -prober specs against the local dev server, causing failures. +prevent prober specs in `tests/probers/` from being discovered by the regular CI +e2e test run. Without this, `npm run test:e2e` would try to execute prober specs +against the local dev server, causing failures. **Modify:** `package.json` @@ -662,8 +655,7 @@ Add a `test:probers` script for running probers locally during development: **Modify:** `tests/tsconfig.json` Add `probers/` to the `include` array so prober specs are type-checked by -`tsc -p tests/tsconfig.json --noEmit` (which runs in CI via -`node.js.yml`). +`tsc -p tests/tsconfig.json --noEmit` (which runs in CI via `node.js.yml`). Current state: @@ -675,35 +667,34 @@ Current state: ``` The existing `./**/*.ts` glob already includes `tests/probers/` — no -modification needed. The `./**/*.d.ts` glob covers type declaration files -and does not affect prober spec discovery. +modification needed. The `./**/*.d.ts` glob covers type declaration files and +does not affect prober spec discovery. **Modify:** `.github/workflows/README.md` Add entries for the four new prober workflows to the file registry, e.g.: ```markdown -- [prober-wikitree.yml](prober-wikitree.yml): Reusable prober that - smoke-tests the WikiTree direct API path on the live WikiTree deployment. - Runs daily and after deploy. -- [prober-gh-pages.yml](prober-gh-pages.yml): Reusable prober that - smoke-tests the GitHub Pages deployment with GEDCOM-from-URL through the - CORS proxy. Runs daily and after deploy. -- [prober-wikitree-cors.yml](prober-wikitree-cors.yml): Reusable prober - that smoke-tests the CORS proxy from the WikiTree deployment with - GEDCOM-from-URL. Runs daily and after deploy. -- [prober-docker.yml](prober-docker.yml): Reusable prober that - smoke-tests the published Docker image from GHCR (Dockerfile, Caddy - config, app startup) by pulling and running it locally. Runs daily and +- [prober-wikitree.yml](prober-wikitree.yml): Reusable prober that smoke-tests + the WikiTree direct API path on the live WikiTree deployment. Runs daily and after deploy. +- [prober-gh-pages.yml](prober-gh-pages.yml): Reusable prober that smoke-tests + the GitHub Pages deployment with GEDCOM-from-URL through the CORS proxy. Runs + daily and after deploy. +- [prober-wikitree-cors.yml](prober-wikitree-cors.yml): Reusable prober that + smoke-tests the CORS proxy from the WikiTree deployment with GEDCOM-from-URL. + Runs daily and after deploy. +- [prober-docker.yml](prober-docker.yml): Reusable prober that smoke-tests the + published Docker image from GHCR (Dockerfile, Caddy config, app startup) by + pulling and running it locally. Runs daily and after deploy. ``` **Create:** `tests/probers/README.md` -Document the prober test directory, explaining that these are live smoke -tests (not hermetic), how to run them locally (`npm run test:probers`), and -that they require network access to external services (WikiTree API, CORS -proxy, GitHub raw URLs). +Document the prober test directory, explaining that these are live smoke tests +(not hermetic), how to run them locally (`npm run test:probers`), and that they +require network access to external services (WikiTree API, CORS proxy, GitHub +raw URLs). **Modify:** `PROJECT_STRUCTURE.md` @@ -715,60 +706,59 @@ Add entries for the new `tests/probers/` directory and Add an entry for this design document to the registry: ```markdown -* **[PROBERS_DESIGN.md](PROBERS_DESIGN.md)**: Live prober smoke tests - against deployed GitHub Pages, WikiTree URLs, and local Docker container, - covering WikiTree API, CORS proxy, GEDCOM-from-URL, and Docker build paths. +- **[PROBERS_DESIGN.md](PROBERS_DESIGN.md)**: Live prober smoke tests against + deployed GitHub Pages, WikiTree URLs, and local Docker container, covering + WikiTree API, CORS proxy, GEDCOM-from-URL, and Docker build paths. ``` ### Summary of all files -| File | Action | Purpose | -|---|---|---| -| `playwright.prober.config.ts` | Create | Separate Playwright config for probers (no local server, live URLs) | -| `playwright.config.ts` | Modify | Add `testIgnore` for `probers/**` to e2e project | -| `package.json` | Modify | Add `test:probers` script | -| `tests/probers/helpers.ts` | Create | Shared prober flow, diagnostics capture, and selector logic | -| `tests/probers/wikitree.spec.ts` | Create | WikiTree direct API smoke test | -| `tests/probers/gh-pages-gedcom.spec.ts` | Create | GitHub Pages + CORS proxy smoke test | -| `tests/probers/wikitree-cors-gedcom.spec.ts` | Create | WikiTree + CORS proxy smoke test | -| `tests/probers/docker.spec.ts` | Create | Docker container smoke test (with reachability guard) | -| `src/pages/view_page.tsx` | Modify | Add `data-testid="content"` | -| `src/chart.tsx` | Modify | Add `data-testid="chart"` | -| `src/sidepanel/details/details.tsx` | Modify | Add `data-testid="details"` | -| `src/components/error_display.tsx` | Modify | Add `data-testid` for error message and popup | -| `.github/workflows/prober-wikitree.yml` | Create | Reusable workflow: WikiTree prober | -| `.github/workflows/prober-gh-pages.yml` | Create | Reusable workflow: GH Pages prober | -| `.github/workflows/prober-wikitree-cors.yml` | Create | Reusable workflow: WikiTree CORS prober | -| `.github/workflows/prober-docker.yml` | Create | Reusable workflow: Docker prober (pulls GHCR image) | -| `.github/workflows/deploy-everywhere.yml` | Modify | Add prober jobs with targeted deploy dependencies | -| `tests/tsconfig.json` | Modify | Ensure prober specs are type-checked | -| `tests/probers/README.md` | Create | Document prober directory and usage | -| `.github/workflows/README.md` | Modify | Document new prober workflows | -| `docs/README.md` | Modify | Add prober design doc to registry | -| `PROJECT_STRUCTURE.md` | Modify | Add prober directory and config file | - +| File | Action | Purpose | +| -------------------------------------------- | ------ | ------------------------------------------------------------------- | +| `playwright.prober.config.ts` | Create | Separate Playwright config for probers (no local server, live URLs) | +| `playwright.config.ts` | Modify | Add `testIgnore` for `probers/**` to e2e project | +| `package.json` | Modify | Add `test:probers` script | +| `tests/probers/helpers.ts` | Create | Shared prober flow, diagnostics capture, and selector logic | +| `tests/probers/wikitree.spec.ts` | Create | WikiTree direct API smoke test | +| `tests/probers/gh-pages-gedcom.spec.ts` | Create | GitHub Pages + CORS proxy smoke test | +| `tests/probers/wikitree-cors-gedcom.spec.ts` | Create | WikiTree + CORS proxy smoke test | +| `tests/probers/docker.spec.ts` | Create | Docker container smoke test (with reachability guard) | +| `src/pages/view_page.tsx` | Modify | Add `data-testid="content"` | +| `src/chart.tsx` | Modify | Add `data-testid="chart"` | +| `src/sidepanel/details/details.tsx` | Modify | Add `data-testid="details"` | +| `src/components/error_display.tsx` | Modify | Add `data-testid` for error message and popup | +| `.github/workflows/prober-wikitree.yml` | Create | Reusable workflow: WikiTree prober | +| `.github/workflows/prober-gh-pages.yml` | Create | Reusable workflow: GH Pages prober | +| `.github/workflows/prober-wikitree-cors.yml` | Create | Reusable workflow: WikiTree CORS prober | +| `.github/workflows/prober-docker.yml` | Create | Reusable workflow: Docker prober (pulls GHCR image) | +| `.github/workflows/deploy-everywhere.yml` | Modify | Add prober jobs with targeted deploy dependencies | +| `tests/tsconfig.json` | Modify | Ensure prober specs are type-checked | +| `tests/probers/README.md` | Create | Document prober directory and usage | +| `.github/workflows/README.md` | Modify | Document new prober workflows | +| `docs/README.md` | Modify | Add prober design doc to registry | +| `PROJECT_STRUCTURE.md` | Modify | Add prober directory and config file | ## 5. Future Considerations ### WikiTree Login Flow Prober -The current WikiTree prober tests the unauthenticated API path (loading a -public profile without an authcode). A future prober could test the -authenticated login flow — logging in with an authcode and verifying that -private profiles are accessible. This would require obtaining a dedicated -test account on wikitree.com and storing the authcode as a GitHub Actions -secret. This is deferred because it adds complexity (secret management, -authcode expiry, test account maintenance) and the unauthenticated path -already covers the most common deployment scenario. +The current WikiTree prober tests the unauthenticated API path (loading a public +profile without an authcode). A future prober could test the authenticated login +flow — logging in with an authcode and verifying that private profiles are +accessible. This would require obtaining a dedicated test account on +wikitree.com and storing the authcode as a GitHub Actions secret. This is +deferred because it adds complexity (secret management, authcode expiry, test +account maintenance) and the unauthenticated path already covers the most common +deployment scenario. ### Google Drive Integration Prober -A prober for the Google Drive integration (loading a GEDCOM file from -Google Drive) is not included. Google's OAuth flow is designed for human -interaction and includes bot detection (CAPTCHA, device verification) that -would likely prevent automated login. Additionally, the Google Drive -integration requires `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` -secrets, which are not available in the prober environment. A possible -workaround would be to use a pre-authorized service account or a long-lived -refresh token stored as a secret, but this is complex and fragile. This is -deferred until a reliable automation approach is identified. +A prober for the Google Drive integration (loading a GEDCOM file from Google +Drive) is not included. Google's OAuth flow is designed for human interaction +and includes bot detection (CAPTCHA, device verification) that would likely +prevent automated login. Additionally, the Google Drive integration requires +`VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` secrets, which are not +available in the prober environment. A possible workaround would be to use a +pre-authorized service account or a long-lived refresh token stored as a secret, +but this is complex and fragile. This is deferred until a reliable automation +approach is identified. diff --git a/docs/README.md b/docs/README.md index 8c06a5a..b5ac51b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,32 @@ # Topola Viewer Feature Designs -This directory contains technical design documents for some of the new features and architectural updates of the Topola Viewer. +This directory contains technical design documents for some of the new features +and architectural updates of the Topola Viewer. -These documents are inspired by the design methodology described in the blog post [Elephants, Goldfish and the New Golden Age of Software Engineering](https://drensin.medium.com/elephants-goldfish-and-the-new-golden-age-of-software-engineering-c33641a48874) by Dave Rensin. +These documents are inspired by the design methodology described in the blog +post +[Elephants, Goldfish and the New Golden Age of Software Engineering](https://drensin.medium.com/elephants-goldfish-and-the-new-golden-age-of-software-engineering-c33641a48874) +by Dave Rensin. -While new features *may* follow this methodology to ensure robust design validation and context safety before writing code, doing so is optional. +While new features _may_ follow this methodology to ensure robust design +validation and context safety before writing code, doing so is optional. ## Design Documents Registry -* **[DOCKER_DESIGN.md](DOCKER_DESIGN.md)**: Docker container packaging, lightweight Caddy web server configuration, and GitHub Actions publishing pipelines. -* **[IMMEDIATE_FAMILY_SECTION_DESIGN.md](IMMEDIATE_FAMILY_SECTION_DESIGN.md)**: Side panel block consolidating parents, spouses, and children for efficient off-screen tree navigation. -* **[PLAYWRIGHT_DESIGN.md](PLAYWRIGHT_DESIGN.md)**: Playwright E2E testing architecture, Vite development/preview server lifecycle integration, tracking blocker interceptors, and embedded iframe communication. -* **[SCREENSHOT_TESTS_DESIGN.md](SCREENSHOT_TESTS_DESIGN.md)**: Pixel-perfect visual regression testing infrastructure, animation stabilization, sandbox environment/DOM sanitization, and isolated Playwright projects. -* **[WEBMCP_DESIGN.md](WEBMCP_DESIGN.md)**: Model Context Protocol (MCP) bridge and TS tool registration for AI agent interaction. -* **[PROBERS_DESIGN.md](PROBERS_DESIGN.md)**: Live prober smoke tests against deployed GitHub Pages, WikiTree URLs, and local Docker container, covering WikiTree API, CORS proxy, GEDCOM-from-URL, and Docker build paths. +- **[DOCKER_DESIGN.md](DOCKER_DESIGN.md)**: Docker container packaging, + lightweight Caddy web server configuration, and GitHub Actions publishing + pipelines. +- **[IMMEDIATE_FAMILY_SECTION_DESIGN.md](IMMEDIATE_FAMILY_SECTION_DESIGN.md)**: + Side panel block consolidating parents, spouses, and children for efficient + off-screen tree navigation. +- **[PLAYWRIGHT_DESIGN.md](PLAYWRIGHT_DESIGN.md)**: Playwright E2E testing + architecture, Vite development/preview server lifecycle integration, tracking + blocker interceptors, and embedded iframe communication. +- **[SCREENSHOT_TESTS_DESIGN.md](SCREENSHOT_TESTS_DESIGN.md)**: Pixel-perfect + visual regression testing infrastructure, animation stabilization, sandbox + environment/DOM sanitization, and isolated Playwright projects. +- **[WEBMCP_DESIGN.md](WEBMCP_DESIGN.md)**: Model Context Protocol (MCP) bridge + and TS tool registration for AI agent interaction. +- **[PROBERS_DESIGN.md](PROBERS_DESIGN.md)**: Live prober smoke tests against + deployed GitHub Pages, WikiTree URLs, and local Docker container, covering + WikiTree API, CORS proxy, GEDCOM-from-URL, and Docker build paths. diff --git a/docs/SCREENSHOT_TESTS_DESIGN.md b/docs/SCREENSHOT_TESTS_DESIGN.md index ac67aae..c70ba6d 100644 --- a/docs/SCREENSHOT_TESTS_DESIGN.md +++ b/docs/SCREENSHOT_TESTS_DESIGN.md @@ -2,142 +2,308 @@ ## 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. +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). +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. +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: +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. + +- **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. + +- **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. + +- **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. + +- **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. +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. +- **[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 `setupHermeticEnvironment()` to abort external tracking requests and embed local fonts (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. +- **`tests/helpers.ts`** + - _Rationale:_ Provide shared E2E/visual testing helper utilities. Features + `setupHermeticEnvironment()` to abort external tracking requests and embed + local fonts (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. + +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 `setupHermeticEnvironment` helper in `beforeEach`. + +1. Define a test block marked with the `@visual` tag, utilizing + `setupHermeticEnvironment` 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. + - 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')`. + +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 establish hermetic routes via `setupHermeticEnvironment(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')`. - * **Immediate Family Rendering Test:** - * Mock `**/family.ged` containing an individual with explicit parental links (`FAMC`) and multi-partner spousal families (`FAMS`) to display biological parents, spouses, and chronologically sorted children blocks. - * Select the individual and wait for `#sidebar` to load. - * Assert sidebar visual representation: `expect(page.locator('#sidebar')).toHaveScreenshot('details-immediate-family.png')`. + +1. Set up a `beforeEach` block to establish hermetic routes via + `setupHermeticEnvironment(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')`. + - **Immediate Family Rendering Test:** + - Mock `**/family.ged` containing an individual with explicit parental + links (`FAMC`) and multi-partner spousal families (`FAMS`) to display + biological parents, spouses, and chronologically sorted children blocks. + - Select the individual and wait for `#sidebar` to load. + - Assert sidebar visual representation: + `expect(page.locator('#sidebar')).toHaveScreenshot('details-immediate-family.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. + +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')`. + - 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. +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`). + +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`. +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. +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/docs/SEARCH_SHORTCUT_DESIGN.md b/docs/SEARCH_SHORTCUT_DESIGN.md index c014d7d..b9a630e 100644 --- a/docs/SEARCH_SHORTCUT_DESIGN.md +++ b/docs/SEARCH_SHORTCUT_DESIGN.md @@ -2,32 +2,102 @@ ## Business Problem -When exploring large family trees in Topola Genealogy, users frequently need to locate specific individuals quickly using the search feature. Currently, accessing the search box requires moving the hand to the mouse or trackpad, navigating the cursor to the top bar, and clicking the input field, which breaks the flow of keyboard-driven navigation. This document proposes introducing a global keyboard shortcut (the `/` key) to instantly focus the search input, allowing users to search without manual mouse interaction. By streamlining this transition, the application provides a faster, more accessible, and premium keyboard-centric workflow for power users navigating complex genealogical data. +When exploring large family trees in Topola Genealogy, users frequently need to +locate specific individuals quickly using the search feature. Currently, +accessing the search box requires moving the hand to the mouse or trackpad, +navigating the cursor to the top bar, and clicking the input field, which breaks +the flow of keyboard-driven navigation. This document proposes introducing a +global keyboard shortcut (the `/` key) to instantly focus the search input, +allowing users to search without manual mouse interaction. By streamlining this +transition, the application provides a faster, more accessible, and premium +keyboard-centric workflow for power users navigating complex genealogical data. --- ## Technical Plan -The shortcut mechanism is designed to be lightweight, modular, and resilient against responsive layout duplication. Rather than introducing complex third-party shortcut libraries, the feature leverages standard web browser event handling, custom React hooks, standard accessibility attributes, and a centralized input registry. +The shortcut mechanism is designed to be lightweight, modular, and resilient +against responsive layout duplication. Rather than introducing complex +third-party shortcut libraries, the feature leverages standard web browser event +handling, custom React hooks, standard accessibility attributes, and a +centralized input registry. ### Major Components and Workflow -1. **The Encapsulated Hook (`useSearchShortcut`):** The keyboard shortcut logic is encapsulated within a custom hook `useSearchShortcut` (`src/menu/use_search_shortcut.ts`). Rather than being called by individual `SearchBar` components, the hook is invoked once at the layout level in `TopBar` (`src/menu/top_bar.tsx`). Individual `SearchBar` instances register their underlying `` DOM elements in a centralized module-level registry (`registeredSearchInputs` `Set`). This ensures a single `window` `keydown` event listener manages focus across all responsive search bar instances. -2. **The Modifier Guard:** To prevent conflicts with system-level and browser-level shortcuts (such as `Cmd + /` for help or extensions, or `Ctrl + /` for toggling comments), the listener ignores keydown events with `Cmd/Meta`. It also ignores `Ctrl` or `Alt` unless both are pressed simultaneously (`Ctrl + Alt`), allowing `AltGr` combinations on international keyboards to function properly. -3. **The IME Composition & Default Prevented Guard:** To avoid hijacking keystrokes when international users are typing using an Input Method Editor (IME) for CJK (Chinese, Japanese, Korean) languages or when another event handler has already intercepted the keypress, the listener ignores keydown events when `event.isComposing === true` or `event.defaultPrevented === true`. -4. **The Smart Filter (Collision, Repeat & Modal Guards):** Before taking action, the listener verifies `event.key === '/'` and checks three conditions: - * **Collision Guard (`isTextEditable`):** It extracts the event path (supporting Shadow DOM encapsulation via `event.composedPath()`) and traverses all nodes in the path to check for ``, `