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

@@ -1,4 +1,6 @@
import {expect, test} from '@playwright/test';
import {test} from '@playwright/test';
import {runProber} from './helpers';
/**
* Prober: Docker container
@@ -11,26 +13,36 @@ import {expect, test} from '@playwright/test';
*
* The workflow starts the container externally (via `docker run`) before
* 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}) => {
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 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();
await runProber(page, {
url: 'http://localhost:8080/',
expectedName: 'Bonifacy',
});
});

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
@@ -10,26 +12,8 @@ import {expect, test} from '@playwright/test';
* deployment, the CORS proxy, and GEDCOM-from-URL loading.
*/
test('GitHub Pages GEDCOM prober', async ({page}) => {
await page.goto(
'https://pewu.github.io/topola-viewer/#/view?url=https://raw.githubusercontent.com/PeWu/topola-viewer/master/src/datasource/testdata/test.ged&indi=I1',
);
// 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();
await runProber(page, {
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',
});
});

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
@@ -9,26 +11,8 @@ import {expect, test} from '@playwright/test';
* the CORS proxy is reachable from the WikiTree deployment.
*/
test('WikiTree CORS proxy prober', async ({page}) => {
await page.goto(
'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',
);
// 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();
await runProber(page, {
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',
});
});

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
@@ -9,26 +11,8 @@ import {expect, test} from '@playwright/test';
* direct API path and confirms the WikiTree deployment is healthy.
*/
test('WikiTree direct API prober', async ({page}) => {
await page.goto(
'https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&indi=Sk%C5%82odowska-2',
);
// 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();
await runProber(page, {
url: 'https://apps.wikitree.com/apps/wiech13/topola-viewer/#/view?source=wikitree&indi=Sk%C5%82odowska-2',
expectedName: 'Skłodowska',
});
});