Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ temp-build
# Playwright output
e2e-tests/.cdp-*
e2e-tests/.dev-*
e2e-tests/.e2e-settings-backup
e2e-tests/playwright-report
e2e-tests/test-results
29 changes: 19 additions & 10 deletions e2e-tests/fixtures/app.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
// Adapted from paranext-core/e2e-tests/fixtures/app.fixture.ts
import {
test as base,
ConsoleMessage,
ElectronApplication,
Page,
TestInfo,
ConsoleMessage,
} from '@playwright/test';
import {
E2E_SETTINGS_OVERRIDES,
launchElectronWithExtension,
teardownElectronApp,
ElectronAppContext,
preConfigureSettings,
PROCESS_READY_TIMEOUT,
teardownElectronApp,
} from './helpers';

export { expect } from '@playwright/test';
Expand All @@ -36,13 +37,21 @@ export const test = base.extend<TestAppFixtures, WorkerAppFixtures>({
electronApp: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const ctx: ElectronAppContext = await launchElectronWithExtension();

await use(ctx.electronApp);

console.log('[teardown] Worker-scoped app teardown starting...');
await teardownElectronApp(ctx);
console.log('[teardown] Worker-scoped app teardown complete — worker will exit now');
console.log('[startup] Configuring settings for worker-scoped app launch...');
const restoreSettings = preConfigureSettings(E2E_SETTINGS_OVERRIDES);
try {
const ctx = await launchElectronWithExtension();
try {
await use(ctx.electronApp);
} finally {
console.log('[teardown] Worker-scoped app teardown starting...');
await teardownElectronApp(ctx);
console.log('[teardown] Worker-scoped app teardown complete');
}
} finally {
restoreSettings();
console.log('[teardown] Original settings restored — worker will exit now');
}
},
{ scope: 'worker' },
],
Expand Down
227 changes: 194 additions & 33 deletions e2e-tests/fixtures/helpers.ts

Large diffs are not rendered by default.

93 changes: 81 additions & 12 deletions e2e-tests/global-setup-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import {
backupAndSeedSettings,
E2E_SETTINGS_OVERRIDES,
restoreBackedUpSettings,
waitForAtLeastOneProjectMetadata,
waitForDockTabTitlesResolved,
waitForInterlinearizerReady,
waitForServiceHostsRegistered,
withFatalStartupTripwire,
} from './fixtures/helpers';
import {
bootstrapRendererDevServer,
DEV_SERVER_PID_FILE,
isPortInUse,
waitForPort,
WEBSOCKET_PORT,
} from './global-setup';
import { killProcessFromPidFile } from './global-teardown';
import { killProcessTree, removeDirWithRetry, waitForProcessExit } from './process-utils';

/**
* Chromium remote-debugging port the self-launched Electron instance exposes and the CDP fixture
Expand Down Expand Up @@ -118,6 +126,10 @@ export default async function globalSetupCdp(_config: FullConfig): Promise<void>
console.log(`Loading extension from: ${extensionDist}`);
console.log(`Remote debugging on port ${CDP_PORT}`);

// Seed E2E_SETTINGS_OVERRIDES before launch and back up settings (restored by globalTeardownCdp
// after the launched app is killed).
seedE2ESettingsOverrides();

// Stream the app's output to a log file rather than discarding it (`stdio: 'ignore'`): when the
// app crashes on startup the only other symptom is an opaque WebSocket-port timeout below.
const appLogFd = fs.openSync(CDP_APP_LOG_FILE, 'w');
Expand Down Expand Up @@ -183,35 +195,63 @@ export default async function globalSetupCdp(_config: FullConfig): Promise<void>
// handled so the eventual teardown kill (same 'exit' event) can't surface as an unhandled
// rejection.
earlyExit.catch(() => {});

// Resolves once the process exits (unlike earlyExit, which rejects). Allows the failure-path
// cleanup to bound its wait for a just-issued SIGKILL to take effect.
const exited = new Promise<void>((resolve) => {
appProcess.once('exit', () => resolve());
});

try {
console.log(`Waiting for PAPI WebSocket on port ${WEBSOCKET_PORT}...`);
await Promise.race([waitForPort(WEBSOCKET_PORT, APP_READY_TIMEOUT), earlyExit]);
console.log(`Waiting for CDP debug port ${CDP_PORT}...`);
await Promise.race([waitForPort(CDP_PORT, APP_READY_TIMEOUT), earlyExit]);
console.log('Ports are up. Waiting for the renderer to settle (dock tabs with real titles)...');
console.log(
'Ports are up. Waiting for the renderer to settle (dock tabs with real titles) and the ' +
'interlinearizer extension to activate...',
);
await Promise.race([waitForRendererSettled(RENDERER_SETTLE_TIMEOUT), earlyExit]);
} catch (error) {
// The app never came up (or came up broken). Echo its captured output so the cause is in the CI
// log itself, not only the uploaded artifact.
dumpAppLog();
// Playwright does not run globalTeardown when globalSetup throws, so clean up everything this
// setup owns right here instead of leaking it to the next run.
const killed = appProcess.pid ? killProcessTree(appProcess.pid, 'SIGKILL') : false;
if (killed) {
await waitForProcessExit(exited, 1_000);
}
await removeDirWithRetry(userDataDir, 'CDP user-data dir');
// Restore settings after waiting for the kill above to take effect. The still-running app owns
// dev-appdata/settings.json, so restoring before it's dead risks flushing the seeded value back
// over the just-restored original, with no backup left to recover from.
restoreSeededSettings();
fs.rmSync(CDP_PID_FILE, { force: true });
fs.rmSync(CDP_USER_DATA_FILE, { force: true });
// Also stop the renderer dev server that bootstrapRendererDevServer() may have started.
killProcessFromPidFile(DEV_SERVER_PID_FILE, 'SIGTERM', 'renderer dev server');
throw error;
}
console.log('Platform.Bible (CDP) is ready.');
}

/**
* Connect to the launched app over CDP and wait for its renderer to settle: the renderer page
* exists and the dock tabs have real titles (none stuck at "Unknown"). This is the earliest point
* at which the tab-title-based locators the feature tests rely on can work, so gating setup on it
* converts a broken shared instance into one fast, diagnosable setup failure instead of a cascade
* of per-test timeouts.
* exists, the dock tabs have real titles (none stuck at "Unknown"), and the interlinearizer
* extension has finished activating (registered `interlinearizer.openForWebView`). This is the
* earliest point at which the tab-title-based locators AND the interlinearizer-opening flow that
* the feature tests rely on can work.
*
* The Playwright connection is closed before returning either way — it only disconnects; the app
* keeps running for the test fixtures to connect to.
*
* @param timeout Maximum time in milliseconds to wait for the renderer page and settled tabs.
* @returns Resolves when the renderer page shows at least one dock tab and no "Unknown" titles.
* @throws {Error} If no renderer page appears or tab titles do not resolve within `timeout`.
* @param timeout Maximum time in milliseconds to wait for the renderer page, settled tabs, and the
* extension's activation.
* @returns Resolves when the renderer page shows at least one dock tab with no "Unknown" titles and
* the interlinearizer extension has registered its open-webview command.
* @throws {Error} If no renderer page appears, tab titles do not resolve, or the extension does not
* activate within `timeout`.
*/
async function waitForRendererSettled(timeout: number): Promise<void> {
const deadline = Date.now() + timeout;
Expand Down Expand Up @@ -254,6 +294,11 @@ async function waitForRendererSettled(timeout: number): Promise<void> {
await waitForDockTabTitlesResolved(page, budgetLeft(), {
strict: true,
});
// Also gate on the interlinearizer extension having finished activating. Else, the first
// feature test's short per-test budget covers activation it was never sized for.
await waitForInterlinearizerReady(budgetLeft());
// Also gate on the bundled WEB project having finished installing.
await waitForAtLeastOneProjectMetadata(budgetLeft());
});
} finally {
// Disconnect only — connectOverCDP close() does not terminate the app.
Expand All @@ -262,14 +307,18 @@ async function waitForRendererSettled(timeout: number): Promise<void> {
}

/**
* Remove the ownership marker files ({@link CDP_PID_FILE}, {@link CDP_USER_DATA_FILE}) if present.
* Called from the warm-instance reuse path, where this run owns none of the resources those markers
* describe, so a stale marker from a prior launched run would make {@link globalTeardownCdp} act on
* foreign resources. Best-effort: each removal is guarded.
* Remove the ownership marker files ({@link CDP_PID_FILE}, {@link CDP_USER_DATA_FILE}) if present and
* restore any settings a prior launched run seeded. Called from the warm-instance reuse path, where
* this run owns none of the resources those markers describe, so a stale marker from a prior
* launched run would make {@link globalTeardownCdp} act on foreign resources (and a stale settings
* seed would linger in the developer's dev-appdata). Best-effort: each removal is guarded.
*
* @returns Nothing.
*/
function clearStaleOwnershipMarkers(): void {
// Undo any seed a prior launched run left behind before it crashed, so the developer's warm
// instance isn't reused with lingering test settings on disk.
restoreSeededSettings();
[CDP_PID_FILE, CDP_USER_DATA_FILE].forEach((markerFile) => {
try {
fs.rmSync(markerFile, { force: true });
Expand All @@ -279,6 +328,26 @@ function clearStaleOwnershipMarkers(): void {
});
}

/**
* Seed {@link E2E_SETTINGS_OVERRIDES} before launching the app. Thin wrapper around
* {@link backupAndSeedSettings} — see its doc for the backup/self-heal behavior.
*
* @returns Nothing.
*/
function seedE2ESettingsOverrides(): void {
backupAndSeedSettings(E2E_SETTINGS_OVERRIDES);
}

/**
* Undo {@link seedE2ESettingsOverrides}. Thin wrapper around {@link restoreBackedUpSettings} — see
* its doc for the restore/self-heal behavior.
*
* @returns Nothing.
*/
export function restoreSeededSettings(): void {
restoreBackedUpSettings();
}

/**
* Print the launched app's captured stdout/stderr to the console. Called when the app fails to open
* its ports so the startup failure's cause appears inline in the CI log.
Expand Down
10 changes: 8 additions & 2 deletions e2e-tests/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import fs from 'fs';
export const WEBSOCKET_PORT = 8876;
export const RENDERER_PORT = 1212;

/**
* File the renderer dev server's PID is written to, for {@link globalTeardown} to kill it — and for
* a CDP setup failure path to kill it too, since Playwright skips `globalTeardown` when
* `globalSetup` throws.
*/
export const DEV_SERVER_PID_FILE = path.join(__dirname, '.dev-server.pid');

/**
* Check if a port is already in use.
*
Expand Down Expand Up @@ -214,9 +221,8 @@ export async function bootstrapRendererDevServer(): Promise<void> {

devServer.unref();

const pidFile = path.join(extensionRoot, 'e2e-tests/.dev-server.pid');
if (devServer.pid) {
fs.writeFileSync(pidFile, String(devServer.pid));
fs.writeFileSync(DEV_SERVER_PID_FILE, String(devServer.pid));
}

console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`);
Expand Down
74 changes: 37 additions & 37 deletions e2e-tests/global-teardown-cdp.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Teardown for the self-launching CDP (feature-test) config.
import type { FullConfig } from '@playwright/test';
import fs from 'fs';
import { CDP_PID_FILE, CDP_USER_DATA_FILE } from './global-setup-cdp';
import { CDP_PID_FILE, CDP_USER_DATA_FILE, restoreSeededSettings } from './global-setup-cdp';
import globalTeardown, { killProcessFromPidFile } from './global-teardown';
import { removeDirWithRetry, waitForPidExit } from './process-utils';

/**
* Playwright global teardown for the CDP config. Kills the Electron instance launched by
Expand All @@ -15,49 +16,48 @@ import globalTeardown, { killProcessFromPidFile } from './global-teardown';
* has completed.
*/
export default async function globalTeardownCdp(config: FullConfig): Promise<void> {
// Kill the app we launched (whole process tree) before the shared teardown's generic sweep.
// SIGKILL (not SIGTERM): Electron can ignore SIGTERM, and we need it fully dead before removing
// its user-data dir below.
const appKilled = killProcessFromPidFile(
CDP_PID_FILE,
'SIGKILL',
'self-launched Platform.Bible (CDP) app',
);
// Snapshot before killProcessFromPidFile unlinks CDP_PID_FILE. `||` not `&&`: a run that crashed
// between globalSetupCdp's two marker writes still owns a seed with only one marker present.
const launchedApp = fs.existsSync(CDP_PID_FILE) || fs.existsSync(CDP_USER_DATA_FILE);

// Remove the isolated user-data dir created for this run. Give the just-killed Electron a moment
// to release the SingletonLock and flush files before removing, then retry once — the smoke
// teardown removes its dir the same defensive way.
if (fs.existsSync(CDP_USER_DATA_FILE)) {
const userDataDir = fs.readFileSync(CDP_USER_DATA_FILE, 'utf-8').trim();
if (userDataDir) {
if (appKilled) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1_000);
});
try {
// Kill the app we launched (whole process tree) before the shared teardown's generic sweep.
// SIGKILL (not SIGTERM): Electron can ignore SIGTERM, and we need it fully dead before removing
// its user-data dir below.
const { killed: appKilled, pid: appPid } = killProcessFromPidFile(
CDP_PID_FILE,
'SIGKILL',
'self-launched Platform.Bible (CDP) app',
);

// Remove the isolated user-data dir created for this run. Give the just-killed Electron a moment
// to actually exit and release the SingletonLock before removing — polling for exit rather than
// listening for it, since teardown only has a bare PID, not a live process handle.
if (fs.existsSync(CDP_USER_DATA_FILE)) {
const userDataDir = fs.readFileSync(CDP_USER_DATA_FILE, 'utf-8').trim();
if (userDataDir) {
if (appKilled && appPid !== undefined) {
await waitForPidExit(appPid, 1_000);
}
await removeDirWithRetry(userDataDir, 'CDP user-data dir');
}
// Guard the marker removal: an fs error here (locked file on Windows, or the file deleted by a
// concurrent run between the existsSync above and this unlink) must not abort teardown before
// the shared cleanup below — that would leak the renderer dev server and lingering Electron.
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch {
await new Promise<void>((resolve) => {
setTimeout(resolve, 3_000);
});
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (e) {
console.warn(`Could not remove CDP user-data dir ${userDataDir}: ${e}`);
}
fs.unlinkSync(CDP_USER_DATA_FILE);
} catch (e) {
console.warn(`Could not remove CDP user-data marker ${CDP_USER_DATA_FILE}: ${e}`);
}
}
// Guard the marker removal: an fs error here (locked file on Windows, or the file deleted by a
// concurrent run between the existsSync above and this unlink) must not abort teardown before
// the shared cleanup below — that would leak the renderer dev server and lingering Electron.
try {
fs.unlinkSync(CDP_USER_DATA_FILE);
} catch (e) {
console.warn(`Could not remove CDP user-data marker ${CDP_USER_DATA_FILE}: ${e}`);
}
} catch (error) {
console.warn(`CDP teardown's own cleanup failed: ${error}`);
}

// Gated like the resources above: the shared backup file (helpers.ts) could otherwise belong to a
// still-in-flight smoke run, not this one.
if (launchedApp) restoreSeededSettings();

// Delegate to the shared teardown to stop the renderer dev server and sweep lingering processes.
await globalTeardown(config);
}
Loading
Loading