mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
refactor: consolidate prober logic into shared helper and add test-testid attributes to core UI elements
This commit is contained in:
22
.github/workflows/prober-docker.yml
vendored
22
.github/workflows/prober-docker.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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": ".",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
105
tests/probers/helpers.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user