refactor: consolidate prober logic into shared helper and add test-testid attributes to core UI elements

This commit is contained in:
Przemek Więch
2026-07-04 21:42:21 +02:00
parent fa2636e8b1
commit 980df35198
12 changed files with 245 additions and 138 deletions

View File

@@ -26,6 +26,12 @@ jobs:
- name: Pull Docker image - name: Pull Docker image
run: docker pull ghcr.io/pewu/topola-viewer:latest run: docker pull ghcr.io/pewu/topola-viewer:latest
- name: Record image digest
run: |
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/pewu/topola-viewer:latest)
echo "Testing Docker image: $DIGEST"
echo "::notice title=Docker Prober::Testing image $DIGEST"
- name: Run container - name: Run container
run: | run: |
docker run -d -p 8080:8080 -e STATIC_URL=test.ged \ docker run -d -p 8080:8080 -e STATIC_URL=test.ged \
@@ -33,14 +39,6 @@ jobs:
--name topola-prober-${{ github.run_id }} \ --name topola-prober-${{ github.run_id }} \
ghcr.io/pewu/topola-viewer:latest ghcr.io/pewu/topola-viewer:latest
- name: Wait for container to be ready
run: |
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:8080/; then break; fi
sleep 1
done
curl -sf -o /dev/null http://localhost:8080/
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -67,6 +65,14 @@ jobs:
if: steps.cache-playwright.outputs.cache-hit != 'true' if: steps.cache-playwright.outputs.cache-hit != 'true'
run: npx playwright install chromium run: npx playwright install chromium
- name: Wait for container to be ready
run: |
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:8080/; then break; fi
sleep 1
done
curl -sf -o /dev/null http://localhost:8080/
- name: Run prober - name: Run prober
env: env:
PLAYWRIGHT_HTML_REPORT: playwright-report/prober PLAYWRIGHT_HTML_REPORT: playwright-report/prober

View File

@@ -258,7 +258,8 @@ Rationale for key configuration decisions:
need for separate e2e/visual projects. All prober specs are smoke tests. need for separate e2e/visual projects. All prober specs are smoke tests.
The project must explicitly use `devices['Desktop Chrome']` to ensure a The project must explicitly use `devices['Desktop Chrome']` to ensure a
desktop viewport, because the side panel visibility depends on desktop viewport, because the side panel visibility depends on
`window.matchMedia('(max-width: 767px)')` (see `src/util/url_args.ts:177`). `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 Without an explicit device, Playwright's default viewport may be too
narrow, causing the side panel to be hidden and the `.details` assertion narrow, causing the side panel to be hidden and the `.details` assertion
to fail. to fail.
@@ -290,7 +291,7 @@ targets a different URL and asserts a different expected name.
deployment. 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 standalone mode when not embedded and no static URL is set (see
`src/util/url_args.ts:198`). `src/util/url_args.ts`).
**Create:** `tests/probers/gh-pages-gedcom.spec.ts` **Create:** `tests/probers/gh-pages-gedcom.spec.ts`
@@ -310,12 +311,12 @@ targets a different URL and asserts a different expected name.
* **Expected name:** `Bonifacy` (same as above). * **Expected name:** `Bonifacy` (same as above).
* **What it exercises:** WikiTree deployment, CORS proxy from the WikiTree * **What it exercises:** WikiTree deployment, CORS proxy from the WikiTree
domain. Even on `apps.wikitree.com`, GEDCOM-from-URL uses the CORS proxy 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:273` by default (the `handleCors` hostname check in `src/datasource/wikitree_api.ts`
only affects WikiTree API calls, not GEDCOM URL fetches in only affects WikiTree API calls, not GEDCOM URL fetches in
`src/datasource/load_data.ts:174`). Note: probers do not block Google Analytics scripts, `src/datasource/load_data.ts`). Note: probers do not block Google Analytics
so live-URL prober runs generate real analytics events on each run. This scripts, so live-URL prober runs generate real analytics events on each
is intentional — the prober tests the unmodified deployed app, and run. This is intentional — the prober tests the unmodified deployed app,
blocking analytics would not reflect the real user experience. and blocking analytics would not reflect the real user experience.
**Create:** `tests/probers/docker.spec.ts` **Create:** `tests/probers/docker.spec.ts`
@@ -339,34 +340,39 @@ targets a different URL and asserts a different expected name.
Note: the Docker prober tests the image published to GHCR by Note: the Docker prober tests the image published to GHCR by
`deploy-docker.yml`, ensuring the published artifact is functional. `deploy-docker.yml`, ensuring the published artifact is functional.
**Shared test structure** (in each spec): **Shared test structure** (in `tests/probers/helpers.ts`, called by each spec):
``` ```
1. Navigate to the target URL. 1. Register browser diagnostics listeners (console errors/warnings, page
2. Wait for #content to be visible. This indicates the app has reached errors, failed network requests) that print to stdout for debugging
`SHOWING_CHART` state and the React tree has rendered the chart live-URL failures.
2. Navigate to the target URL with waitUntil: 'domcontentloaded' to avoid
waiting for analytics scripts.
3. Wait for data-testid="content" to be visible. This indicates the app has
reached `SHOWING_CHART` state and the React tree has rendered the chart
container. Note: `#content` becomes visible *before* the D3 chart SVG container. Note: `#content` becomes visible *before* the D3 chart SVG
is populated — the actual chart text is rendered by a `useEffect` in is populated — the actual chart text is rendered by a `useEffect` in
the `Chart` component that fires after `#content` appears. The the `Chart` component that fires after `#content` appears. The
subsequent `#chart` text assertion relies on Playwright's auto-wait subsequent `#chart` text assertion relies on Playwright's auto-wait
to bridge this gap. to bridge this gap.
3. Assert expected name appears in #chart (chart SVG text). 4. Assert expected name appears in data-testid="chart" (chart SVG text).
4. Assert expected name appears in div.details (side panel). 5. Assert expected name appears in data-testid="details" (side panel).
5. Assert .ui.error.message is not visible (no fatal error). 6. Assert data-testid="error-message" is not visible (no fatal error).
6. Assert .ui.errorPopup.message is not visible (no popup error). 7. Assert data-testid="error-popup" is not visible (no popup error).
``` ```
The `.ui.errorPopup.message` selector must be scoped at the document level The `data-testid="error-popup"` selector works at the document level because
(e.g., `page.locator('.ui.errorPopup.message')`), not scoped to `#content`, Playwright's `getByTestId` searches the entire document, so it matches the
because `ErrorPopup` uses Semantic UI React's `<Portal>` which renders at `ErrorPopup` content rendered by Semantic UI React's `<Portal>` at
`document.body` level. `document.body` level.
Selectors are derived from the source code: Selectors are derived from the source code:
* `#content` — main container, visible when chart state is `SHOWING_CHART` * `#content` — main container, visible when chart state is `SHOWING_CHART`
(see `src/pages/view_page.tsx:202`). (see the `renderMainArea` function in `src/pages/view_page.tsx`).
* `#chart` — SVG group inside the chart (see `src/chart.tsx:599`). * `#chart` — SVG group inside the chart (see `src/chart.tsx`).
* `div.details` — side panel Details tab content (see `src/sidepanel/details/details.tsx:357`). * `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 * `.ui.error.message` — fatal error replacing the chart (see
`src/components/error_display.tsx`, rendered when state is `ERROR`). The `ui` and `src/components/error_display.tsx`, rendered when state is `ERROR`). The `ui` and
`message` classes are added by Semantic UI React's `<Message>` `message` classes are added by Semantic UI React's `<Message>`
@@ -385,9 +391,18 @@ Selectors are derived from the source code:
The side panel is expanded by default on desktop viewports (the prober project The side panel is expanded by default on desktop viewports (the prober project
uses `devices['Desktop Chrome']`). The `getShowSidePanel` function in uses `devices['Desktop Chrome']`). The `getShowSidePanel` function in
`src/util/url_args.ts:177` returns `true` on non-mobile screens, so the `div.details` `src/util/url_args.ts` returns `true` on non-mobile screens, so the `div.details`
container is visible without any URL parameters. 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.
### Step 3: Prober GitHub Actions workflows ### Step 3: Prober GitHub Actions workflows
All prober workflows should declare minimal permissions for security: All prober workflows should declare minimal permissions for security:
@@ -488,13 +503,22 @@ before testing).
(Pull the image published by deploy-docker.yml. This tests the actual (Pull the image published by deploy-docker.yml. This tests the actual
published artifact, not a local build. The GHCR package is public, so published artifact, not a local build. The GHCR package is public, so
no `docker login` authentication step is required.) no `docker login` authentication step is required.)
3. Run container: docker run -d -p 8080:8080 -e STATIC_URL=test.ged 3. Record image digest: docker inspect --format='{{index .RepoDigests 0}}'
ghcr.io/pewu/topola-viewer:latest. Prints the full digest (e.g.,
ghcr.io/pewu/topola-viewer@sha256:abc123...) to the workflow log and
as a GitHub Actions notice. This provides traceability — if the prober
fails, you can verify which exact image was tested.
4. Run container: docker run -d -p 8080:8080 -e STATIC_URL=test.ged
-v $(pwd)/src/datasource/testdata/test.ged:/app/public/test.ged -v $(pwd)/src/datasource/testdata/test.ged:/app/public/test.ged
--name topola-prober-${{ github.run_id }} --name topola-prober-${{ github.run_id }}
ghcr.io/pewu/topola-viewer:latest ghcr.io/pewu/topola-viewer:latest
(Use a unique container name with github.run_id to prevent name (Use a unique container name with github.run_id to prevent name
conflicts if a previous run didn't clean up or if runs overlap.) conflicts if a previous run didn't clean up or if runs overlap.)
4. Wait for container to be ready: use a bash retry loop with `curl` to 5. Setup Node.js 24.x with npm cache.
6. Run npm ci.
7. Get Playwright version (same pattern as node.js.yml).
8. Cache and install Playwright (same as live-URL probers).
9. Wait for container to be ready: use a bash retry loop with `curl` to
poll `http://localhost:8080/` until it responds with HTTP 200 poll `http://localhost:8080/` until it responds with HTTP 200
(timeout 30s, 1s interval): (timeout 30s, 1s interval):
```bash ```bash
@@ -506,17 +530,20 @@ before testing).
``` ```
The final `curl` ensures the workflow fails with a clear error if The final `curl` ensures the workflow fails with a clear error if
the container never became ready. This prevents a race condition the container never became ready. This prevents a race condition
where the test runs before Caddy is ready to serve requests. where the test runs before Caddy is ready to serve requests. This
5. Setup Node.js 24.x with npm cache. step runs after Node/Playwright setup so the container doesn't sit
6. Run npm ci. idle during dependency installation.
7. Get Playwright version (same pattern as node.js.yml). The Docker spec also includes a guard that checks if localhost:8080
8. Cache and install Playwright (same as live-URL probers). is reachable before running the prober. If the container is not
9. Run: npx playwright test --config=playwright.prober.config.ts docker.spec.ts running (e.g., when running probers locally without Docker), the
10. Upload Playwright HTML report as artifact (if: always()). Set 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). `retention-days: 30` (same as live-URL probers).
11. Stop and remove container (if: always()): docker stop 12. Stop and remove container (if: always()): docker stop
topola-prober-${{ github.run_id }} 2>/dev/null; docker rm topola-prober-${{ github.run_id }} 2>/dev/null || true; docker rm
topola-prober-${{ github.run_id }} 2>/dev/null; true topola-prober-${{ github.run_id }} 2>/dev/null || true
(The if: always() ensures cleanup runs even on failure. The (The if: always() ensures cleanup runs even on failure. The
2>/dev/null and trailing true prevent errors if the container was 2>/dev/null and trailing true prevent errors if the container was
never started, e.g., pull failed at step 2.) never started, e.g., pull failed at step 2.)
@@ -700,10 +727,15 @@ Add an entry for this design document to the registry:
| `playwright.prober.config.ts` | Create | Separate Playwright config for probers (no local server, live URLs) | | `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 | | `playwright.config.ts` | Modify | Add `testIgnore` for `probers/**` to e2e project |
| `package.json` | Modify | Add `test:probers` script | | `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/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/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/wikitree-cors-gedcom.spec.ts` | Create | WikiTree + CORS proxy smoke test |
| `tests/probers/docker.spec.ts` | Create | Docker container 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-wikitree.yml` | Create | Reusable workflow: WikiTree prober |
| `.github/workflows/prober-gh-pages.yml` | Create | Reusable workflow: GH Pages 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-wikitree-cors.yml` | Create | Reusable workflow: WikiTree CORS prober |

View File

@@ -103,7 +103,7 @@
"test:visual": "playwright test --project=visual", "test:visual": "playwright test --project=visual",
"test:visual:update": "playwright test --project=visual --update-snapshots", "test:visual:update": "playwright test --project=visual --update-snapshots",
"test:visual:ui": "playwright test --project=visual --ui", "test:visual:ui": "playwright test --project=visual --ui",
"test:probers": "playwright test --config=playwright.prober.config.ts gh-pages-gedcom.spec.ts wikitree.spec.ts wikitree-cors-gedcom.spec.ts", "test:probers": "playwright test --config=playwright.prober.config.ts",
"check:all": "prettier --check \"{src,tests}/**/*.{ts,tsx,json}\" && npm run lint && npm run build && npm test && tsc -p tests/tsconfig.json --noEmit && npm run test:e2e && npm run test:visual" "check:all": "prettier --check \"{src,tests}/**/*.{ts,tsx,json}\" && npm run lint && npm run build && npm test && tsc -p tests/tsconfig.json --noEmit && npm run test:e2e && npm run test:visual"
}, },
"homepage": ".", "homepage": ".",

View File

@@ -596,7 +596,7 @@ export function Chart(props: ChartProps) {
</button> </button>
</Media> </Media>
<svg id="chartSvg"> <svg id="chartSvg">
<g id="chart" /> <g id="chart" data-testid="chart" />
</svg> </svg>
</div> </div>
); );

View File

@@ -4,7 +4,7 @@ import {Message, Portal} from 'semantic-ui-react';
/** Shows an error message in the middle of the screen. */ /** Shows an error message in the middle of the screen. */
export function ErrorMessage(props: {message?: string}) { export function ErrorMessage(props: {message?: string}) {
return ( return (
<Message negative className="error"> <Message negative className="error" data-testid="error-message">
<Message.Header> <Message.Header>
<FormattedMessage <FormattedMessage
id="error.failed_to_load_file" id="error.failed_to_load_file"
@@ -28,7 +28,7 @@ export interface ErrorPopupProps {
export function ErrorPopup(props: ErrorPopupProps) { export function ErrorPopup(props: ErrorPopupProps) {
return ( return (
<Portal open={props.open} onClose={props.onDismiss}> <Portal open={props.open} onClose={props.onDismiss}>
<Message negative className="errorPopup" onDismiss={props.onDismiss}> <Message negative className="errorPopup" onDismiss={props.onDismiss} data-testid="error-popup">
<Message.Header> <Message.Header>
<FormattedMessage id="error.error" defaultMessage={'Error'} /> <FormattedMessage id="error.error" defaultMessage={'Error'} />
</Message.Header> </Message.Header>

View File

@@ -199,7 +199,7 @@ export function ViewPage() {
} }
const selection = updatedSelection; const selection = updatedSelection;
return ( return (
<div id="content"> <div id="content" data-testid="content">
<ErrorPopup <ErrorPopup
open={showErrorPopup} open={showErrorPopup}
message={error} message={error}

View File

@@ -354,7 +354,7 @@ export function Details(props: Props) {
const entries = props.gedcom.indis[props.indi].tree; const entries = props.gedcom.indis[props.indi].tree;
return ( return (
<div className="details"> <div className="details" data-testid="details">
<Item.Group divided> <Item.Group divided>
{getSectionForEachMatchingEntry( {getSectionForEachMatchingEntry(
entries, entries,

View File

@@ -1,4 +1,6 @@
import {expect, test} from '@playwright/test'; import {test} from '@playwright/test';
import {runProber} from './helpers';
/** /**
* Prober: Docker container * Prober: Docker container
@@ -11,26 +13,36 @@ import {expect, test} from '@playwright/test';
* *
* The workflow starts the container externally (via `docker run`) before * The workflow starts the container externally (via `docker run`) before
* this test runs, so Playwright connects to localhost:8080 directly. * this test runs, so Playwright connects to localhost:8080 directly.
*
* If the Docker container is not running (e.g., when running probers locally
* without Docker), this test is skipped with a clear message rather than
* failing with a confusing connection error.
*/ */
test('Docker container prober', async ({page}) => { test('Docker container prober', async ({page}) => {
await page.goto('http://localhost:8080/'); // Guard: verify the Docker container is reachable before running the
// prober. When running locally without Docker, this produces a clear
// skip message instead of a confusing ECONNREFUSED failure.
// page.request.get throws on connection refused (rather than returning a
// non-OK response), so we catch the error and skip.
let containerReachable = false;
try {
const response = await page.request.get('http://localhost:8080/', {
timeout: 5_000,
});
containerReachable = response.ok();
} catch {
containerReachable = false;
}
test.skip(
!containerReachable,
'Docker container is not reachable on localhost:8080 — start it with: ' +
'docker run -d -p 8080:8080 -e STATIC_URL=test.ged ' +
'-v $(pwd)/src/datasource/testdata/test.ged:/app/public/test.ged ' +
'ghcr.io/pewu/topola-viewer:latest',
);
// Wait for the app to reach SHOWING_CHART state. await runProber(page, {
await expect(page.locator('#content')).toBeVisible(); url: 'http://localhost:8080/',
expectedName: 'Bonifacy',
// Assert the expected person's name appears in the chart SVG. });
await expect(page.locator('#chart')).toContainText('Bonifacy');
// Assert the expected person's name appears in the side panel.
// Scoped to div.details to avoid matching <text class="details sex"> SVG
// elements in the chart that also carry the "details" class.
await expect(page.locator('div.details')).toContainText('Bonifacy');
// Assert no fatal error is displayed (replaces chart when state is ERROR).
await expect(page.locator('.ui.error.message')).not.toBeVisible();
// Assert no popup error is displayed.
// ErrorPopup uses Semantic UI React's <Portal>, which renders at
// document.body level, not inside #content.
await expect(page.locator('.ui.errorPopup.message')).not.toBeVisible();
}); });

View File

@@ -1,4 +1,6 @@
import {expect, test} from '@playwright/test'; import {test} from '@playwright/test';
import {runProber} from './helpers';
/** /**
* Prober: GitHub Pages GEDCOM via CORS proxy * Prober: GitHub Pages GEDCOM via CORS proxy
@@ -10,26 +12,8 @@ import {expect, test} from '@playwright/test';
* deployment, the CORS proxy, and GEDCOM-from-URL loading. * deployment, the CORS proxy, and GEDCOM-from-URL loading.
*/ */
test('GitHub Pages GEDCOM prober', async ({page}) => { test('GitHub Pages GEDCOM prober', async ({page}) => {
await page.goto( await runProber(page, {
'https://pewu.github.io/topola-viewer/#/view?url=https://raw.githubusercontent.com/PeWu/topola-viewer/master/src/datasource/testdata/test.ged&indi=I1', url: 'https://pewu.github.io/topola-viewer/#/view?url=https://raw.githubusercontent.com/PeWu/topola-viewer/master/src/datasource/testdata/test.ged&indi=I1',
); expectedName: 'Bonifacy',
});
// Wait for the app to reach SHOWING_CHART state.
await expect(page.locator('#content')).toBeVisible();
// Assert the expected person's name appears in the chart SVG.
await expect(page.locator('#chart')).toContainText('Bonifacy');
// Assert the expected person's name appears in the side panel.
// Scoped to div.details to avoid matching <text class="details sex"> SVG
// elements in the chart that also carry the "details" class.
await expect(page.locator('div.details')).toContainText('Bonifacy');
// Assert no fatal error is displayed (replaces chart when state is ERROR).
await expect(page.locator('.ui.error.message')).not.toBeVisible();
// Assert no popup error is displayed.
// ErrorPopup uses Semantic UI React's <Portal>, which renders at
// document.body level, not inside #content.
await expect(page.locator('.ui.errorPopup.message')).not.toBeVisible();
}); });

105
tests/probers/helpers.ts Normal file
View File

@@ -0,0 +1,105 @@
import {expect, type Page} from '@playwright/test';
/**
* Registers console, page-error, and network-failure listeners on the given
* page. Playwright captures these in trace files, but also printing them to
* stdout makes them visible in the GitHub Actions log — essential for
* debugging live-URL prober failures where the failure is hard to reproduce.
*
* Listeners are registered once per page and remain active for the test's
* lifetime.
*/
export function captureDiagnostics(page: Page): void {
page.on('console', (msg) => {
const type = msg.type();
if (type === 'error' || type === 'warning') {
console.log(`[browser ${type}] ${msg.text()}`);
}
});
page.on('pageerror', (err) => {
console.log(`[page error] ${err.message}`);
});
page.on('requestfailed', (req) => {
const failure = req.failure();
console.log(
`[request failed] ${req.url()}${failure?.errorText ?? 'unknown'}`,
);
});
}
export interface ProberOptions {
/** Full URL to navigate to. */
url: string;
/** Expected person name to assert appears in the chart and side panel. */
expectedName: string;
/**
* Optional navigation timeout in milliseconds. Defaults to 60s — separate
* from the test-level timeout so a hung navigation doesn't eat the entire
* test budget before assertions even begin.
*/
navigationTimeout?: number;
}
/**
* Runs the standard prober flow against a live URL:
*
* 1. Navigate to the target URL.
* 2. Wait for #content (data-testid="content") to be visible — indicates the
* app reached SHOWING_CHART state.
* 3. Assert the expected person's name appears in the chart SVG.
* 4. Assert the expected person's name appears in the side panel details.
* 5. Assert no fatal error message is displayed.
* 6. Assert no popup error is displayed.
*
* All selectors use `data-testid` attributes for stability — they survive
* CSS class refactors and Semantic UI React internal changes.
*/
export async function runProber(
page: Page,
options: ProberOptions,
): Promise<void> {
const {url, expectedName, navigationTimeout = 60_000} = options;
captureDiagnostics(page);
// Use 'domcontentloaded' instead of the default 'load' so we don't wait
// for analytics scripts and third-party resources that are irrelevant to
// the prober's assertions.
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: navigationTimeout,
});
// Wait for the app to reach SHOWING_CHART state. #content becomes visible
// before the D3 chart SVG is populated — the subsequent #chart text
// assertion relies on Playwright's auto-wait to bridge this gap.
//
// Selectors use a union of data-testid and legacy CSS/ID selectors so the
// prober works against both the currently deployed app (which predates
// data-testid) and future deployments that include the attributes.
const content = page.locator('[data-testid="content"], #content');
const chart = page.locator('[data-testid="chart"], #chart');
const details = page.locator('[data-testid="details"], div.details');
const errorMessage = page.locator(
'[data-testid="error-message"], .ui.error.message',
);
const errorPopup = page.locator(
'[data-testid="error-popup"], .ui.errorPopup.message',
);
await expect(content).toBeVisible();
// Assert the expected person's name appears in the chart SVG.
await expect(chart).toContainText(expectedName);
// Assert the expected person's name appears in the side panel.
await expect(details).toContainText(expectedName);
// Assert no fatal error is displayed (replaces chart when state is ERROR).
await expect(errorMessage).not.toBeVisible();
// Assert no popup error is displayed. ErrorPopup uses Semantic UI React's
// <Portal>, which renders at document.body level — these locators search
// the entire document, so this works regardless of DOM nesting.
await expect(errorPopup).not.toBeVisible();
}

View File

@@ -1,4 +1,6 @@
import {expect, test} from '@playwright/test'; import {test} from '@playwright/test';
import {runProber} from './helpers';
/** /**
* Prober: WikiTree GEDCOM + CORS proxy * Prober: WikiTree GEDCOM + CORS proxy
@@ -9,26 +11,8 @@ import {expect, test} from '@playwright/test';
* the CORS proxy is reachable from the WikiTree deployment. * the CORS proxy is reachable from the WikiTree deployment.
*/ */
test('WikiTree CORS proxy prober', async ({page}) => { test('WikiTree CORS proxy prober', async ({page}) => {
await page.goto( await runProber(page, {
'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', 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',
); expectedName: 'Bonifacy',
});
// Wait for the app to reach SHOWING_CHART state.
await expect(page.locator('#content')).toBeVisible();
// Assert the expected person's name appears in the chart SVG.
await expect(page.locator('#chart')).toContainText('Bonifacy');
// Assert the expected person's name appears in the side panel.
// Scoped to div.details to avoid matching <text class="details sex"> SVG
// elements in the chart that also carry the "details" class.
await expect(page.locator('div.details')).toContainText('Bonifacy');
// Assert no fatal error is displayed (replaces chart when state is ERROR).
await expect(page.locator('.ui.error.message')).not.toBeVisible();
// Assert no popup error is displayed.
// ErrorPopup uses Semantic UI React's <Portal>, which renders at
// document.body level, not inside #content.
await expect(page.locator('.ui.errorPopup.message')).not.toBeVisible();
}); });

View File

@@ -1,4 +1,6 @@
import {expect, test} from '@playwright/test'; import {test} from '@playwright/test';
import {runProber} from './helpers';
/** /**
* Prober: WikiTree direct API * Prober: WikiTree direct API
@@ -9,26 +11,8 @@ import {expect, test} from '@playwright/test';
* direct API path and confirms the WikiTree deployment is healthy. * direct API path and confirms the WikiTree deployment is healthy.
*/ */
test('WikiTree direct API prober', async ({page}) => { test('WikiTree direct API prober', async ({page}) => {
await page.goto( await runProber(page, {
'https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&indi=Sk%C5%82odowska-2', url: 'https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&indi=Sk%C5%82odowska-2',
); expectedName: 'Skłodowska',
});
// Wait for the app to reach SHOWING_CHART state.
await expect(page.locator('#content')).toBeVisible();
// Assert the expected person's name appears in the chart SVG.
await expect(page.locator('#chart')).toContainText('Skłodowska');
// Assert the expected person's name appears in the side panel.
// Scoped to div.details to avoid matching <text class="details sex"> SVG
// elements in the chart that also carry the "details" class.
await expect(page.locator('div.details')).toContainText('Skłodowska');
// Assert no fatal error is displayed (replaces chart when state is ERROR).
await expect(page.locator('.ui.error.message')).not.toBeVisible();
// Assert no popup error is displayed.
// ErrorPopup uses Semantic UI React's <Portal>, which renders at
// document.body level, not inside #content.
await expect(page.locator('.ui.errorPopup.message')).not.toBeVisible();
}); });