Skip to content

Commit c54e30d

Browse files
committed
feat: implement unified Playwright E2E testing for /run page with CI integration
- Add Playwright configuration and environment-aware settings in frontend/playwright.config.ts - Create initial E2E tests in frontend/tests/e2e/run-page.spec.ts to verify Run Timeline visibility - Integrate E2E tests into the Husky pre-push hook for automated verification - Update package.json scripts for E2E testing and add necessary dependencies - Enhance documentation to reflect new testing strategy and implementation details
1 parent a939fc7 commit c54e30d

File tree

12 files changed

+914
-56
lines changed

12 files changed

+914
-56
lines changed

.DS_Store

2 KB
Binary file not shown.

.husky/pre-push

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/bin/sh
22
# Husky pre-push hook
3-
# Runs smoke tests before allowing push
3+
# Runs smoke tests + E2E tests before allowing push
44

55
echo "🧪 Running smoke tests before push (bun run qa:smoke)..."
66
echo ""
@@ -9,5 +9,12 @@ echo ""
99
bun run qa:smoke || exit 1
1010

1111
echo ""
12-
echo "✅ All smoke tests passed - proceeding with push"
12+
echo "🎭 Running E2E tests before push (bun run test:e2e:ci)..."
13+
echo ""
14+
15+
# Run E2E tests in headless CI mode
16+
bun run test:e2e:ci || exit 1
17+
18+
echo ""
19+
echo "✅ All smoke tests and E2E tests passed - proceeding with push"
1320

frontend/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
"format": "bunx biome format --write .",
1313
"format:check": "bunx biome format --write=false .",
1414
"lint": "bunx biome lint .",
15-
"lint:fix": "bunx biome lint --apply ."
15+
"lint:fix": "bunx biome lint --apply .",
16+
"test:e2e": "playwright test",
17+
"test:e2e:headed": "HEADLESS=false playwright test",
18+
"test:e2e:ci": "HEADLESS=true playwright test",
19+
"test:e2e:ui": "playwright test --ui"
1620
},
1721
"dependencies": {
1822
"@formkit/auto-animate": "^0.8.2",
@@ -24,6 +28,7 @@
2428
},
2529
"devDependencies": {
2630
"@biomejs/biome": "1.9.2",
31+
"@playwright/test": "^1.48.0",
2732
"@skeletonlabs/skeleton": "^4.2.4",
2833
"@skeletonlabs/skeleton-svelte": "^4.2.4",
2934
"@sveltejs/adapter-vercel": "^4.0.0",

frontend/playwright.config.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
import { resolve, dirname } from "node:path";
3+
import { readFileSync } from "node:fs";
4+
import { fileURLToPath } from "node:url";
5+
6+
/** Get __dirname equivalent in ES modules */
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = dirname(__filename);
9+
10+
/** Load .env file manually to ensure test environment matches app environment */
11+
const envPath = resolve(__dirname, "../.env");
12+
try {
13+
const envFile = readFileSync(envPath, "utf-8");
14+
for (const line of envFile.split("\n")) {
15+
const trimmed = line.trim();
16+
if (!trimmed || trimmed.startsWith("#")) continue;
17+
const [key, ...valueParts] = trimmed.split("=");
18+
if (key && valueParts.length > 0) {
19+
process.env[key] = valueParts.join("=");
20+
}
21+
}
22+
} catch (error) {
23+
console.warn("⚠️ Could not load .env file, using defaults");
24+
}
25+
26+
/** Detect CI environment (GitHub Actions, GitLab CI, etc.) */
27+
const CI = !!process.env.CI;
28+
29+
/** Control headless mode - defaults to true, can be overridden with HEADLESS=false */
30+
const HEADLESS = process.env.HEADLESS !== "false";
31+
32+
/** Frontend URL - defaults to standard port from .env */
33+
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173";
34+
35+
/**
36+
* Playwright configuration for ScreenGraph frontend E2E tests.
37+
*
38+
* Environment-aware setup:
39+
* - Local dev: headed mode with slowMo for debugging
40+
* - CI: headless, retries enabled, video on failure
41+
* - Test package from .env: VITE_PACKAGE_NAME
42+
*/
43+
export default defineConfig({
44+
testDir: "./tests/e2e",
45+
fullyParallel: false, // Run tests sequentially for stability
46+
forbidOnly: CI, // Prevent .only() in CI
47+
retries: CI ? 2 : 0, // Retry flaky tests in CI only
48+
workers: 1, // Single worker for now
49+
reporter: CI ? "github" : "list",
50+
51+
use: {
52+
baseURL: FRONTEND_URL,
53+
headless: HEADLESS,
54+
trace: "on-first-retry",
55+
video: CI ? "retain-on-failure" : "off",
56+
screenshot: "only-on-failure",
57+
58+
// Slow motion in local dev for visual debugging
59+
launchOptions: {
60+
slowMo: CI ? 0 : 150,
61+
},
62+
},
63+
64+
projects: [
65+
{
66+
name: "chromium",
67+
use: { ...devices["Desktop Chrome"] },
68+
},
69+
],
70+
71+
// Auto-start frontend if not running (optional)
72+
webServer: {
73+
command: "bun run dev",
74+
url: FRONTEND_URL,
75+
reuseExistingServer: true,
76+
timeout: 120_000,
77+
},
78+
});
79+

frontend/tests/e2e/helpers.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/**
4+
* Reusable Playwright helper utilities for ScreenGraph E2E tests.
5+
*
6+
* Provides consistent patterns for:
7+
* - Waiting for elements
8+
* - Safe interactions
9+
* - Common navigation patterns
10+
*
11+
* All tests use configuration from .env file for consistency.
12+
*/
13+
14+
/**
15+
* Test package name from .env - the main key for all E2E tests.
16+
* Defaults to com.example.testapp if not set in environment.
17+
*/
18+
export const TEST_PACKAGE_NAME = process.env.VITE_PACKAGE_NAME || "com.example.testapp";
19+
20+
/**
21+
* Test app configuration from .env for consistent E2E testing.
22+
* All tests run against the same package defined in .env.
23+
*/
24+
export const TEST_APP_CONFIG = {
25+
packageName: process.env.VITE_PACKAGE_NAME || "com.example.testapp",
26+
appActivity: process.env.VITE_APP_ACTIVITY || "com.example.testapp.MainActivity",
27+
apkPath: process.env.VITE_APK_PATH || "/path/to/test.apk",
28+
appiumServerUrl: process.env.VITE_APPIUM_SERVER_URL || "http://localhost:4723",
29+
};
30+
31+
/**
32+
* Wait for a specific text to appear on the page.
33+
* Useful for waiting on dynamic content or SSE events.
34+
*/
35+
export async function waitForText(
36+
page: Page,
37+
text: string | RegExp,
38+
options?: { timeout?: number },
39+
): Promise<void> {
40+
const timeout = options?.timeout ?? 30000;
41+
await page.waitForSelector(`text=${text}`, { timeout });
42+
}
43+
44+
/**
45+
* Start a run from the landing page and wait for navigation to /run.
46+
* Returns the run ID extracted from the URL.
47+
*
48+
* NOTE: Uses TEST_PACKAGE_NAME from .env for consistency.
49+
*/
50+
export async function startRunFromLanding(page: Page): Promise<string> {
51+
// Navigate to landing page
52+
await page.goto("/");
53+
54+
// Click "Detect My First Drift" button
55+
const runButton = page.getByRole("button", { name: /detect.*drift/i });
56+
await runButton.click();
57+
58+
// Wait for navigation to /run page
59+
await page.waitForURL(/\/run\/([a-f0-9-]+)/i, {
60+
waitUntil: "domcontentloaded",
61+
timeout: 30000,
62+
});
63+
64+
// Extract run ID from URL
65+
const url = page.url();
66+
const match = url.match(/\/run\/([a-f0-9-]+)/i);
67+
if (!match) {
68+
throw new Error("Failed to extract run ID from URL");
69+
}
70+
71+
return match[1];
72+
}
73+
74+
/**
75+
* Wait for a graph event to appear in the timeline.
76+
* Requires data-testid or data-event attributes on timeline entries.
77+
*/
78+
export async function waitForGraphEvent(
79+
page: Page,
80+
eventName: string,
81+
options?: { timeout?: number },
82+
): Promise<void> {
83+
const timeout = options?.timeout ?? 45000;
84+
await page.waitForSelector(`[data-event="${eventName}"]`, { timeout });
85+
}
86+
87+
/**
88+
* Capture console errors during test execution.
89+
* Returns an array of error messages.
90+
*/
91+
export function captureConsoleErrors(page: Page): string[] {
92+
const errors: string[] = [];
93+
94+
page.on("console", (msg) => {
95+
if (msg.type() === "error") {
96+
errors.push(msg.text());
97+
}
98+
});
99+
100+
page.on("pageerror", (error) => {
101+
errors.push(error.message);
102+
});
103+
104+
return errors;
105+
}
106+
107+
/**
108+
* Wait for the stop node to appear in the run timeline.
109+
* Indicates the run has completed successfully.
110+
*/
111+
export async function waitForStopNode(
112+
page: Page,
113+
options?: { timeout?: number },
114+
): Promise<void> {
115+
const timeout = options?.timeout ?? 60000;
116+
await page.waitForSelector('[data-run-state="stopped"]', { timeout });
117+
}
118+
119+
/**
120+
* Count discovered screenshots in the gallery.
121+
* Returns the number of screenshot images rendered.
122+
*/
123+
export async function countDiscoveredScreenshots(page: Page): Promise<number> {
124+
const screenGallery = page.locator(
125+
'[data-testid="discovered-screens"] img',
126+
);
127+
return await screenGallery.count();
128+
}
129+
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect } from "@playwright/test";
2+
import { TEST_PACKAGE_NAME, TEST_APP_CONFIG } from "./helpers";
3+
4+
/**
5+
* /run page E2E regression suite
6+
*
7+
* Verifies core functionality:
8+
* - Run page loads after starting a run
9+
* - Run Timeline header is visible
10+
*
11+
* Prerequisites:
12+
* - Backend and frontend services running
13+
* - Test package from .env: ${TEST_PACKAGE_NAME}
14+
* - All tests use the same package for consistency
15+
*/
16+
test.describe("/run page smoke tests", () => {
17+
test.beforeAll(() => {
18+
// Log test configuration from .env
19+
console.log("🎯 E2E Test Configuration:");
20+
console.log(` Package: ${TEST_APP_CONFIG.packageName}`);
21+
console.log(` Activity: ${TEST_APP_CONFIG.appActivity}`);
22+
console.log(` Appium: ${TEST_APP_CONFIG.appiumServerUrl}`);
23+
});
24+
25+
/**
26+
* Verify run page opens and displays Run Timeline header.
27+
* This is the baseline test for detecting UI regressions.
28+
*
29+
* NOTE: Requires backend to be running and able to start runs.
30+
* Uses package from .env: ${TEST_PACKAGE_NAME}
31+
*/
32+
test("should open run page and show Run Timeline text", async ({ page }) => {
33+
// Navigate to landing page
34+
await page.goto("/");
35+
36+
// Wait for page to fully load
37+
await expect(page).toHaveTitle(/ScreenGraph/i);
38+
39+
// Find and click the "Detect My First Drift" button
40+
// Using getByRole for accessibility-friendly selection
41+
const runButton = page.getByRole("button", { name: /detect.*drift/i });
42+
await expect(runButton).toBeVisible();
43+
44+
// Click the button - this will call the backend API and navigate
45+
await runButton.click();
46+
47+
// Wait for navigation to /run page (increased timeout for API call + navigation)
48+
await page.waitForURL(/\/run\/[a-f0-9-]+/i, {
49+
waitUntil: "domcontentloaded",
50+
timeout: 30000
51+
});
52+
53+
// Verify Run Timeline text is visible in the H1 heading
54+
// This is the key regression check - if timeline UI breaks, this will fail
55+
// Use getByRole to be specific and avoid strict mode violations
56+
const timelineHeading = page.getByRole("heading", { name: /run timeline/i });
57+
await expect(timelineHeading).toBeVisible({ timeout: 10000 });
58+
59+
// Verify Cancel Run button exists (indicates page fully loaded)
60+
const cancelButton = page.getByRole("button", { name: /cancel run/i });
61+
await expect(cancelButton).toBeVisible();
62+
});
63+
64+
/**
65+
* Verify landing page loads correctly before testing run flow.
66+
* This is a sanity check to ensure frontend is healthy.
67+
*/
68+
test("should load landing page successfully", async ({ page }) => {
69+
await page.goto("/");
70+
71+
// Verify page title
72+
await expect(page).toHaveTitle(/ScreenGraph/i);
73+
74+
// Verify the main CTA button exists
75+
const runButton = page.getByRole("button", { name: /detect.*drift/i });
76+
await expect(runButton).toBeVisible();
77+
78+
// Verify no console errors on load
79+
const consoleErrors: string[] = [];
80+
page.on("console", (msg) => {
81+
if (msg.type() === "error") {
82+
consoleErrors.push(msg.text());
83+
}
84+
});
85+
86+
await page.waitForTimeout(1000);
87+
88+
// Assert no console errors
89+
expect(consoleErrors).toHaveLength(0);
90+
});
91+
});
92+

0 commit comments

Comments
 (0)