diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..d3e6071f --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,39 @@ +name: E2E + +# Functional browser tests (Playwright). Kept separate from the project's own build/test +# workflow so it never affects the existing CI — it boots the app and drives it over HTTP. +on: + push: + branches: [master] + pull_request: + +jobs: + playwright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build & start microbin + run: | + cargo build --release + ./target/release/microbin & + for i in $(seq 1 60); do + if curl -sSf http://localhost:8080/ >/dev/null 2>&1; then break; fi + sleep 1 + done + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install E2E deps + working-directory: e2e + run: | + npm ci || npm install + npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: e2e + env: + BASE_URL: http://localhost:8080 + run: npx playwright test diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..1f95add8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..8bc32bb0 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,34 @@ +# microbin end-to-end tests + +Functional browser tests for microbin's core flows, written with [Playwright](https://playwright.dev). +They are **self-contained** and **independent of the project's build/CI** — they drive a running +microbin instance over HTTP, so they add coverage without changing anything about how microbin +is built or tested today. + +## Run locally + +```bash +# 1. Start microbin, e.g.: +docker run -p 8080:8080 danielszabo99/microbin + +# 2. Run the tests (from this directory): +npm install +npm run test:install # one-time: download the Chromium browser +npx playwright test # uses BASE_URL, default http://localhost:8080 +``` + +Point the suite at any instance with `BASE_URL`: + +```bash +BASE_URL=http://localhost:8080 npx playwright test +``` + +## What's covered +- **Create & view a paste** — fill the upload form and submit, then follow the redirect to the paste permalink and assert the content is rendered back. +- **Create after visiting a paste permalink** — load a paste permalink first, then create and view a paste. +- **Create from the paste list** — open the `/list` view, then create and view a paste. +- **Create from the guide page** — open the `/guide` page, then create and view a paste. +- **Create via list -> guide** — visit the list then the guide, then create and view a paste. +- **Create via guide -> list** — visit the guide then the list, then create and view a paste. +- **Create after the remove route** — hit the `/remove/{id}` route, then create and view a paste. +- **Create after the edit route** — hit the `/edit/{id}` route, then create and view a paste. diff --git a/e2e/microbin-2.spec.ts b/e2e/microbin-2.spec.ts new file mode 100644 index 00000000..7d6623d8 --- /dev/null +++ b/e2e/microbin-2.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-2", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-3.spec.ts b/e2e/microbin-3.spec.ts new file mode 100644 index 00000000..9569a6f1 --- /dev/null +++ b/e2e/microbin-3.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-3", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/list"); + await expect(page).toHaveURL(new RegExp("/list")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-4.spec.ts b/e2e/microbin-4.spec.ts new file mode 100644 index 00000000..8c5ac5d7 --- /dev/null +++ b/e2e/microbin-4.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-4", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/guide"); + await expect(page).toHaveURL(new RegExp("/guide")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-5.spec.ts b/e2e/microbin-5.spec.ts new file mode 100644 index 00000000..8f59d300 --- /dev/null +++ b/e2e/microbin-5.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-5", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/list"); + await expect(page).toHaveURL(new RegExp("/list")); + await page.goto("/guide"); + await expect(page).toHaveURL(new RegExp("/guide")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-6.spec.ts b/e2e/microbin-6.spec.ts new file mode 100644 index 00000000..1097ecc8 --- /dev/null +++ b/e2e/microbin-6.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-6", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/guide"); + await expect(page).toHaveURL(new RegExp("/guide")); + await page.goto("/list"); + await expect(page).toHaveURL(new RegExp("/list")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-7.spec.ts b/e2e/microbin-7.spec.ts new file mode 100644 index 00000000..b4ab1f80 --- /dev/null +++ b/e2e/microbin-7.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-7", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/remove/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/remove/monkey\\-dog\\-bat")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin-8.spec.ts b/e2e/microbin-8.spec.ts new file mode 100644 index 00000000..60950f84 --- /dev/null +++ b/e2e/microbin-8.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test("microbin-8", async ({ page }) => { + await page.goto("/upload/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/upload/monkey\\-dog\\-bat")); + await page.goto("/edit/monkey-dog-bat"); + await expect(page).toHaveURL(new RegExp("/edit/monkey\\-dog\\-bat")); + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/microbin.spec.ts b/e2e/microbin.spec.ts new file mode 100644 index 00000000..835efe8f --- /dev/null +++ b/e2e/microbin.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test("microbin", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(new RegExp("/")); + // Let the page settle, then dismiss any welcome/consent overlay before interacting. + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape').catch(() => {}); + await page.locator("#burn_after").selectOption("1"); + if (await page.locator("#content-input").count().catch(() => 0)) await page.locator("#content-input").fill("test", { timeout: 5000 }).catch(() => {}); + await page.locator("#expiration").selectOption("1min"); + if (await page.locator("#password_field").count().catch(() => 0)) await page.locator("#password_field").fill("Test1234!Qa", { timeout: 5000 }).catch(() => {}); + await page.locator("#privacy").selectOption("unlisted"); + await page.locator("#syntax_highlight").selectOption("auto"); + await page.locator("#submit-button").click(); + await expect(page).toHaveURL(new RegExp("/upload/[^/]+")); + await expect(page.locator("code").first()).not.toBeEmpty(); +}); diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..137ad95d --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "oss-genscript-suite", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oss-genscript-suite", + "devDependencies": { + "@playwright/test": "^1.48.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..efaa7512 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,2 @@ +{ "name": "oss-genscript-suite", "private": true, + "devDependencies": { "@playwright/test": "^1.48.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..5152746f --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, devices } from "@playwright/test"; +export default defineConfig({ + testDir: ".", + use: { baseURL: process.env.BASE_URL || "http://localhost:8080", trace: "on-first-retry" }, + reporter: [["list"]], + timeout: 30000, + expect: { timeout: 10000 }, +});