From f74d531c4df387ab4d99941468e5a5d271c18ab7 Mon Sep 17 00:00:00 2001 From: vijay-prema Date: Thu, 18 Jun 2026 05:03:34 +0000 Subject: [PATCH] feat(e2e): add some playwright e2e tests --- .devcontainer/devcontainer.json | 5 + .gitignore | 11 ++ e2e/README.md | 131 +++++++++++++++++ .../aida_sentence_run.test.ts | 110 ++++++++++++++ .../browse_filter_pagination.test.ts | 134 ++++++++++++++++++ .../geographic_example.test.ts | 85 +++++++++++ e2e/lib/helpers.ts | 64 +++++++++ e2e/tsconfig.json | 15 ++ e2e/vitest.config.ts | 23 +++ package-lock.json | 30 ++-- package.json | 6 +- 11 files changed, 594 insertions(+), 20 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/aida_sentence_run/aida_sentence_run.test.ts create mode 100644 e2e/browse_filter_pagination/browse_filter_pagination.test.ts create mode 100644 e2e/geographic_example/geographic_example.test.ts create mode 100644 e2e/lib/helpers.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/vitest.config.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3916a80..9256d50 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -34,6 +34,11 @@ "ghcr.io/devcontainers-extra/features/uv:1": {}, "ghcr.io/postfinance/devcontainer-features/browsers:1.0.0": { "firefoxVersion": "latest" + }, + "ghcr.io/postfinance/devcontainer-features/playwright-deps:1.0.0": { + "installChromiumDeps": true, + "installFirefoxDeps": false, + "installWebkitDeps": false } }, "postCreateCommand": "bash scripts/devcontainer-setup.sh", diff --git a/.gitignore b/.gitignore index 0befd23..1555a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Personal notes step-by-step.md +# Script output +scripts/output/ + # Dependencies node_modules/ .pnp @@ -65,3 +68,11 @@ api/package-lock.json # Artifacts generated from running nektos act to simulate github actions (npm run pr-check) .artifacts + +# Python env +.venv + +# e2e test artifacts +e2e/**/screenshots +e2e/**/*_log.txt +e2e/**/plan.md diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..d51aa12 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,131 @@ +# E2E tests + +End-to-end tests that drive a real browser ([Playwright](https://playwright.dev)) through the Science Live Platform web app. These can mainly be generated and maintained by any competent coding agent with browser-use capability. + +Each test lives in its own folder and follows the same shape: + +``` +e2e/ + vitest.config.ts # Vitest config scoped to e2e/ (see below) + tsconfig.json # Editor / type-check support for the e2e tests + lib/ + helpers.ts # Shared helpers: BASE_URL, screenshots, logging, browser launch + geographic_example/ + geographic_example.test.ts + aida_sentence_run/ + aida_sentence_run.test.ts + browse_filter_pagination/ + browse_filter_pagination.test.ts + + ... etc +``` + +Per-test artifacts (`screenshots/` and `*_log.txt`) are written next to each test +and are gitignored (see the root `.gitignore`). + +## Prerequisites + +To ensure Playwright is properly installed, after `npm install` at the repo +root, install the browser binaries once (not needed on machines where they are +already present, or if using the devcontainer which pre-installs it): + +```sh +npx playwright install +``` + +## Run the tests + +From the repo root, to run every e2e test: +```sh +npm run test:e2e +``` + +Filter and run a specific test (e.g. the geographic_example test): +```sh +npm run test:e2e -- geographic +``` + +Interactive watch mode (re-runs on file changes): +```sh +npm run test:e2e:watch +``` + +These E2E tests are intentionally **not** part of `npm test` (which only runs the +frontend/api unit tests via workspaces) - they hit a live server and are slow, so +they are just run manually for now. + +### Configuring the target instance (BASE_URL) + +By default the tests run against the production deployment: https://platform.sciencelive4all.org + +Override this with the `E2E_BASE_URL` environment variable to target a different +instance (e.g. a local dev server, a PR preview, or a staging deploy): + +```sh +E2E_BASE_URL=http://localhost:3000 npm run test:e2e +``` + +The active base URL is recorded at the top of each test's `*_log.txt`. + +### Watching the browser / choosing the browser engine + +Tests run headless by default. Set `E2E_HEADED=1` to launch a visible browser +window - useful when debugging a flaky flow manually: + +```sh +E2E_HEADED=1 npm run test:e2e -- geographic +``` + +The default browser engine is **firefox**. Set `E2E_BROWSER` to `chromium` or `webkit` to use a different engine: + +```sh +E2E_BROWSER=chromium npm run test:e2e +``` + +## How the tests are structured + +Each test is a single Vitest `test()` that walks a user flow as a sequence of +**critical points** (CP1, CP2, …). Verifiable checkpoints use `expect.soft(...)` +so the test runs the whole flow and reports _every_ failed checkpoint rather than +bailing on the first one. + +Each step is also logged to the test's `*_log.txt` and a screenshot is captured +into `screenshots/`. + +The browser is launched once per test file via the shared `lib/helpers.ts`. + +## Generate a new test + +To generate a new E2E test in natural language, ask your agent something like: + +``` +I want to generate a new E2E test for Science Live Platform. Follow the patterns +set by the existing tests under the e2e/ folder (TypeScript + Vitest + Playwright, +see e2e/README.md and e2e/lib/helpers.ts). + +The new test should perform these steps: + +Go to https://platform.sciencelive4all.org, go to the browse page, wait for the +search to load, then press the Next button to view the next page of search +results. The label at the bottom should read "Page 2" instead of "Page 1" - if it +does not, report that as an error. +``` + +This assumes: + +- You want to test the prod deployment (`https://platform.sciencelive4all.org`) by + default - changeable via `E2E_BASE_URL` (or the `getBaseUrl()` default in + `lib/helpers.ts`). +- The described flow actually works to completion so the agent can navigate the + site and generate a faithful script. + +If the app changes and a test breaks, ask the agent to fix/modify the existing +test in a similar way, or regenerate it from scratch mentioning any new changes. + +## TODO + +- [x] Make it easy to configure which `BASE_URL` to run tests on (`E2E_BASE_URL`). +- [x] Add an npm script that runs these via a test runner (Vitest). +- [ ] Integrate with a CI/CD pipeline / GitHub workflow as part of PR checks. +- [ ] Optionally auto-start a local instance/container and run the E2E tests + against it when `E2E_BASE_URL` is not set. diff --git a/e2e/aida_sentence_run/aida_sentence_run.test.ts b/e2e/aida_sentence_run/aida_sentence_run.test.ts new file mode 100644 index 0000000..3b00443 --- /dev/null +++ b/e2e/aida_sentence_run/aida_sentence_run.test.ts @@ -0,0 +1,110 @@ +import type { Browser, Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createArtifacts, getBaseUrl, launchBrowser, testDir } from "../lib/helpers"; + +// Example content used to fill the AIDA Sentence form. +const AIDA_SENTENCE = "The protein p53 inhibits tumor growth in human cells."; +const PROJECT_URI = "https://w3id.org/np/RA4fmfVFULMP50FqDFX8fEMn66uDF07vXKFXh_L9aoQKE"; + +/** + * Create an AIDA Sentence Nanopublication on Science Live Platform. + * + * Navigates to the platform, creates an AIDA Sentence nanopublication with + * example content, generates it (without publishing), and verifies the preview + * contains Template View, RDF View, and TriG View tabs with content. + */ +describe("AIDA Sentence nanopublication creation", () => { + const baseUrl = getBaseUrl(); + const artifacts = createArtifacts("aida_sentence_run", testDir(import.meta.url)); + + let browser: Browser | undefined; + let page!: Page; + + beforeAll(async () => { + browser = await launchBrowser(); + page = await browser.newPage({ viewport: { width: 1280, height: 1800 } }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + test("creates an AIDA Sentence nanopublication and verifies the preview tabs", async () => { + // CP1 - Navigate to platform + artifacts.log(`CP1: Navigating to ${baseUrl}`); + await page.goto(baseUrl, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "01_homepage.png"); + + // CP2 - Navigate to the Create page + artifacts.log("CP2: Clicking Create nav button to go to Create page"); + await page + .getByRole("navigation") + .getByRole("button", { name: "Create", exact: true }) + .click(); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "02_create_page.png"); + + // CP3 - Select the AIDA Sentence template + artifacts.log("CP3: Selecting AIDA Sentence template"); + await page.getByRole("button", { name: "AIDA Sentence Make structured" }).first().click(); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "03_template_selected.png"); + + // CP4 - Fill in example content + artifacts.log(`CP4: Filling AIDA sentence: '${AIDA_SENTENCE}'`); + await page.getByPlaceholder("Enter sentence.").fill(AIDA_SENTENCE); + artifacts.log(`CP4: Filling project URI: '${PROJECT_URI}'`); + await page + .getByPlaceholder("URI of nanopublication for related research project") + .fill(PROJECT_URI); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "04_form_filled.png"); + + // CP5 - Generate the nanopublication (not publish) + artifacts.log("CP5: Clicking Generate Nanopublication button (not Publish)"); + await page.getByRole("button", { name: "Generate Nanopublication" }).click(); + await page.waitForTimeout(5000); + await artifacts.screenshot(page, "05_generated.png"); + + // CP6 - Verify the preview section appears below the form + artifacts.log("CP6: Verifying preview section appears below the form"); + await page.getByRole("heading", { name: "PREVIEW:" }).waitFor({ timeout: 10_000 }); + await artifacts.screenshot(page, "06_preview_visible.png"); + + // CP7 - Verify the Template View tab exists and shows content + artifacts.log("CP7: Verifying Template View tab exists and shows content"); + await page.getByRole("tab", { name: "Template View" }).click(); + await page.waitForTimeout(500); + const templateSnapshot = await page.ariaSnapshot(); + await artifacts.screenshot(page, "07_template_view.png"); + const templateHasContent = + templateSnapshot.includes('tabpanel "Template View"') && + templateSnapshot.includes("AIDA Sentence"); + expect.soft(templateHasContent, "CP7 - Template View has content").toBe(true); + + // CP8 - Verify the RDF View tab exists and shows content + artifacts.log("CP8: Verifying RDF View tab exists and shows content"); + await page.getByRole("tab", { name: "RDF View" }).click(); + await page.waitForTimeout(500); + const rdfSnapshot = await page.ariaSnapshot(); + await artifacts.screenshot(page, "08_rdf_view.png"); + const rdfHasContent = + rdfSnapshot.includes('tabpanel "RDF View"') && + rdfSnapshot.includes("Assertion") && + rdfSnapshot.includes("AIDA-Sentence"); + expect.soft(rdfHasContent, "CP8 - RDF View has content").toBe(true); + + // CP9 - Verify the TriG View tab exists and shows content + artifacts.log("CP9: Verifying TriG View tab exists and shows content"); + await page.getByRole("tab", { name: "TriG View" }).click(); + await page.waitForTimeout(500); + const trigSnapshot = await page.ariaSnapshot(); + await artifacts.screenshot(page, "09_trig_view.png"); + const trigHasContent = + trigSnapshot.includes('tabpanel "TriG View"') && + trigSnapshot.includes("@prefix") && + trigSnapshot.includes("sub:assertion"); + expect.soft(trigHasContent, "CP9 - TriG View has content").toBe(true); + }); +}); diff --git a/e2e/browse_filter_pagination/browse_filter_pagination.test.ts b/e2e/browse_filter_pagination/browse_filter_pagination.test.ts new file mode 100644 index 0000000..7bab0e6 --- /dev/null +++ b/e2e/browse_filter_pagination/browse_filter_pagination.test.ts @@ -0,0 +1,134 @@ +import type { Browser, Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createArtifacts, getBaseUrl, launchBrowser, testDir } from "../lib/helpers"; + +/** + * Browse, Filter and Pagination on Science Live Platform. + * + * Navigates to the browse page, tests filtering by template type, sorting, + * pagination, and clearing filters, verifying each operation works correctly. + */ +describe("Browse, filter and pagination", () => { + const baseUrl = getBaseUrl(); + const artifacts = createArtifacts("browse_filter_pagination", testDir(import.meta.url)); + + let browser: Browser | undefined; + let page!: Page; + + beforeAll(async () => { + browser = await launchBrowser(); + page = await browser.newPage({ viewport: { width: 1280, height: 2500 } }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + test("filters, sorts and paginates the browse page", async () => { + // CP1 - Navigate to homepage + artifacts.log(`CP1: Navigating to ${baseUrl}`); + await page.goto(baseUrl, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "01_homepage.png"); + + // CP2 - Navigate to Browse page and wait for results to load + artifacts.log("CP2: Clicking Browse link to navigate to browse page"); + await page.getByRole("link", { name: "Browse" }).click(); + await page.waitForLoadState("networkidle", { timeout: 30_000 }); + await page.waitForTimeout(5000); + await artifacts.screenshot(page, "02_browse_page.png"); + expect.soft(page.url().includes("/np/"), "CP2 - Browse page reached").toBe(true); + + // CP3 - Wait for nanopublications to load + artifacts.log("CP3: Waiting for nanopublications to load"); + await page + .locator("text=Loading") + .waitFor({ state: "hidden", timeout: 10_000 }) + .catch(() => {}); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "03_results_loaded.png"); + + // CP4 - Filter by Core + artifacts.log("CP4: Clicking 'Core' filter checkbox"); + await page.locator("label", { hasText: "Core" }).first().click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "04_core_filter.png"); + const coreClearVisible = + (await page.getByRole("button", { name: "Clear filters" }).count()) > 0; + const coreHeadingVisible = + (await page.getByRole("heading", { name: "Nanopublications with selected Template(s)" }).count()) > + 0; + const coreAria = await page.ariaSnapshot(); + const coreHasResults = coreAria.includes("AIDA") || coreAria.includes("Core"); + expect.soft(coreClearVisible && coreHeadingVisible && coreHasResults, "CP4 - Core filter applied").toBe( + true, + ); + + // CP5 - Sort by Most Referenced + artifacts.log("CP5: Clicking sort dropdown and selecting 'Most Referenced'"); + await page.getByRole("combobox").click(); + await page.waitForTimeout(500); + await artifacts.screenshot(page, "05_sort_dropdown.png"); + await page.getByRole("option", { name: "Most Referenced" }).click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "06_most_referenced_sorted.png"); + const mostRefSortValue = await page.getByRole("combobox").innerText(); + expect.soft(mostRefSortValue.includes("Most Referenced"), "CP5 - Sorted by Most Referenced").toBe( + true, + ); + + // CP6 - Click Next to go to Page 2 + artifacts.log("CP6: Clicking 'Next' button to go to Page 2"); + const nextBtn = page.getByRole("button", { name: "Next" }); + let page2Displayed = false; + if ((await nextBtn.count()) > 0 && !(await nextBtn.isDisabled())) { + await nextBtn.click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "07_page_2.png"); + page2Displayed = (await page.locator("text=Page 2").count()) > 0; + } else { + artifacts.log("CP6: Next button not available or disabled - skipping"); + } + expect.soft(page2Displayed, "CP6 - Page 2 displayed").toBe(true); + + // CP7 - Change sort back to Newest First and verify it returns to Page 1 + artifacts.log("CP7: Changing sort back to 'Newest First'"); + await page.getByRole("combobox").click(); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Newest first" }).click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "08_newest_first.png"); + const newestSortValue = await page.getByRole("combobox").innerText(); + const newestSorted = newestSortValue.includes("Newest"); + const backToPage1 = (await page.locator("text=Page 1").count()) > 0; + expect.soft(newestSorted && backToPage1, "CP7 - Newest First sort and Page 1 restored").toBe(true); + + // CP8 - Clear filters + artifacts.log("CP8: Clicking 'Clear filters' button"); + await page.getByRole("button", { name: "Clear filters" }).click(); + await page.waitForTimeout(5000); + await artifacts.screenshot(page, "09_filters_cleared.png"); + let heading = page.getByRole("heading", { name: "Latest Nanopublications" }); + if ((await heading.count()) === 0) { + await page.waitForTimeout(2000); + heading = page.getByRole("heading", { name: "Latest Nanopublications" }); + } + const filtersCleared = (await heading.count()) > 0; + expect.soft(filtersCleared, "CP8 - Filters cleared").toBe(true); + + // CP9 - Filter by PRISMA Database Search + artifacts.log("CP9: Clicking 'PRISMA Database Search' filter"); + await page.locator("label", { hasText: "PRISMA Database Search" }).first().click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "10_prisma_filter.png"); + const prismaClearVisible = + (await page.getByRole("button", { name: "Clear filters" }).count()) > 0; + const prismaHeadingVisible = + (await page.getByRole("heading", { name: "Nanopublications with selected Template(s)" }).count()) > + 0; + expect.soft( + prismaClearVisible && prismaHeadingVisible, + "CP9 - PRISMA Database Search filter applied", + ).toBe(true); + }); +}); diff --git a/e2e/geographic_example/geographic_example.test.ts b/e2e/geographic_example/geographic_example.test.ts new file mode 100644 index 0000000..437c68d --- /dev/null +++ b/e2e/geographic_example/geographic_example.test.ts @@ -0,0 +1,85 @@ +import type { Browser, Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createArtifacts, getBaseUrl, launchBrowser, testDir } from "../lib/helpers"; + +/** + * Geographic Example on Science Live Platform. + * + * Navigates to the browse page, clicks the Geographic tab, clicks the first + * example ("Data about Crabs around Southern Europe"), and verifies that + * 5 locations are found. + */ +describe("Geographic example", () => { + const baseUrl = getBaseUrl(); + const artifacts = createArtifacts("geographic_example", testDir(import.meta.url)); + + let browser: Browser | undefined; + let page!: Page; + + beforeAll(async () => { + browser = await launchBrowser(); + page = await browser.newPage({ viewport: { width: 1280, height: 2500 } }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + test("navigates to the Geographic tab and finds 5 locations", async () => { + // CP1 - Navigate to homepage + artifacts.log(`CP1: Navigating to ${baseUrl}`); + await page.goto(baseUrl, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "01_homepage.png"); + + // CP2 - Navigate to Browse page and wait for results to load + artifacts.log("CP2: Clicking Browse link to navigate to browse page"); + await page.getByRole("link", { name: "Browse" }).click(); + await page.waitForLoadState("networkidle", { timeout: 30_000 }); + await page.waitForTimeout(5000); + // Best-effort: wait for the "Loading" indicator to disappear. + await page + .locator("text=Loading") + .waitFor({ state: "hidden", timeout: 10_000 }) + .catch(() => {}); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "02_browse_page.png"); + const browseReached = + page.url().includes("/np/") || (await page.ariaSnapshot()).includes("Browse"); + expect.soft(browseReached, "CP2 - Browse page reached").toBe(true); + + // CP3 - Click the Geographic tab and verify it is selected + artifacts.log("CP3: Clicking the Geographic tab"); + await page.getByRole("tab", { name: "Geographic" }).click(); + await page.waitForTimeout(3000); + await artifacts.screenshot(page, "03_geographic_tab.png"); + const tabSnapshot = await page.ariaSnapshot(); + expect.soft( + tabSnapshot.includes('tab "Geographic" [selected]'), + "CP3 - Geographic tab selected", + ).toBe(true); + + // CP4 - Click the first example and verify the search box fills with "crab" + artifacts.log("CP4: Clicking the first example: 'Data about Crabs around Southern Europe'"); + await page.getByRole("button", { name: "Data about Crabs around Southern Europe" }).click(); + await page.waitForTimeout(5000); + await artifacts.screenshot(page, "04_example_clicked.png"); + const searchValue = await page + .getByRole("textbox", { name: "Enter search query..." }) + .inputValue(); + expect.soft( + searchValue.toLowerCase().includes("crab"), + "CP4 - Search textbox filled with 'crab'", + ).toBe(true); + + // CP5 - Verify that 5 locations are found + artifacts.log("CP5: Verifying that 5 locations are found"); + await page.locator("text=locations found").waitFor({ timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(2000); + await artifacts.screenshot(page, "05_locations_found.png"); + const locationsSnapshot = await page.ariaSnapshot(); + const inSnapshot = locationsSnapshot.includes("5 locations found"); + const visible = (await page.locator("text=5 locations found").count()) > 0; + expect.soft(inSnapshot || visible, "CP5 - 5 locations found").toBe(true); + }); +}); diff --git a/e2e/lib/helpers.ts b/e2e/lib/helpers.ts new file mode 100644 index 0000000..785feee --- /dev/null +++ b/e2e/lib/helpers.ts @@ -0,0 +1,64 @@ +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, firefox, webkit, type Browser, type Page } from "playwright"; + +/** Default deployment under test. Override with the E2E_BASE_URL env var. */ +export const DEFAULT_BASE_URL = "https://platform.sciencelive4all.org"; + +/** Base URL under test. Set E2E_BASE_URL to target a different instance. */ +export function getBaseUrl(): string { + return process.env.E2E_BASE_URL?.trim() || DEFAULT_BASE_URL; +} + +/** Directory of the calling test file, so each test writes artifacts next to itself. */ +export function testDir(metaUrl: string): string { + return dirname(fileURLToPath(metaUrl)); +} + +export interface Artifacts { + /** Save a screenshot into the test's screenshots/ folder (gitignored). */ + screenshot: (page: Page, filename: string) => Promise; + /** Append a step/action line to the test log and mirror it to stdout. */ + log: (message: string) => void; +} + +/** + * Set up per-test artifacts: a screenshots/ directory and a *_log.txt file + * (both gitignored — see the root .gitignore). Returns helpers bound to that + * test's directory. + */ +export function createArtifacts(testName: string, dir: string): Artifacts { + const screenshotsDir = join(dir, "screenshots"); + mkdirSync(screenshotsDir, { recursive: true }); + + const logFile = join(dir, `${testName}_log.txt`); + writeFileSync( + logFile, + `=== ${testName} E2E Test ===\nStarted: ${new Date().toISOString()}\nBase URL: ${getBaseUrl()}\n\n`, + ); + + return { + async screenshot(page: Page, filename: string) { + await page.screenshot({ path: join(screenshotsDir, filename) }); + }, + log(message: string) { + const line = `[${new Date().toISOString()}] ${message}`; + appendFileSync(logFile, line + "\n"); + console.log(message); + }, + }; +} + +/** + * Launch the browser used for E2E tests. Defaults to headless firefox. + * Set E2E_HEADED=1 to watch the browser run, + * which is handy when debugging a flaky flow manually. Set E2E_BROWSER to + * "chromium" or "webkit" to use a different engine. + */ +export async function launchBrowser(): Promise { + const headed = process.env.E2E_HEADED === "1" || process.env.E2E_HEADED === "true"; + const name = (process.env.E2E_BROWSER ?? "firefox").toLowerCase(); + const engine = name === "chromium" ? chromium : name === "webkit" ? webkit : firefox; + return engine.launch({ headless: !headed }); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..3dc869f --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 0000000..04e3556 --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,23 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// E2E tests for the Science Live Platform. These drive a real browser against a +// deployed (or local) instance — see e2e/README.md. Run with: npm run test:e2e +export default defineConfig({ + test: { + // Keep test discovery scoped to the e2e/ folder regardless of cwd, so this + // config never picks up the frontend/api unit tests. + root: here, + include: ["**/*.test.ts"], + // Each test drives a real browser against a live server with network waits + // (and RSA signing in the create flow), so allow plenty of time. + testTimeout: 180_000, + hookTimeout: 180_000, + // Run files one at a time: they each launch a browser and share the same + // target instance, and sequential runs give clearer manual output. + fileParallelism: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index 7009ddc..f409754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,10 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.6.0", "eslint-plugin-jsonc": "^3.1.2", + "playwright": "^1.61.0", "prettier-plugin-organize-imports": "^4.3.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.4" } }, "api": { @@ -28273,14 +28275,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/pdfjs-dist": { - "resolved": "git+ssh://git@github.com/zotero-plugin-dev/zotero-pdfjs-types.git#16a81ee97a2369ddd661e68fc4acbc5211fa00e8", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=18" - } - }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -28483,14 +28477,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -28503,12 +28496,11 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -36305,7 +36297,7 @@ }, "zotero": { "name": "zotero-sciencelive", - "version": "1.0.4", + "version": "1.0.6", "dependencies": { "@nanopub/nanopub-js": "~0.1.1", "showdown": "^2.1.0", diff --git a/package.json b/package.json index fafc342..7ba6050 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "dev:api-bun": "npm run dev-bun --workspace=api", "build": "npm run build --workspace=frontend", "test": "npm run test --workspaces", + "test:e2e": "vitest run --config e2e/vitest.config.ts", + "test:e2e:watch": "vitest --config e2e/vitest.config.ts", "check:frontend": "npm --workspace=frontend run check", "check:api": "npm --workspace=api run check", "deploy:frontend": "npm --workspace=frontend run deploy", @@ -27,8 +29,10 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.6.0", "eslint-plugin-jsonc": "^3.1.2", + "playwright": "^1.61.0", "prettier-plugin-organize-imports": "^4.3.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.4" }, "dependencies": { "dotenv": "^17.4.2"