diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..bee009a7f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c5092441..64dcd5238 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + lfs: true - uses: actions/download-artifact@v4 with: name: test-deployment-files @@ -213,22 +215,18 @@ jobs: run: | docker-compose -f docker-compose.yml up -d \ tobira-login-handler \ - tobira-meilisearch - - - name: Rebuild search index - run: ./tobira search-index update --config util/dev-config/config.toml - + tobira-meilisearch \ + tobira-ui-test-files + - name: Link Tobira binary to location expected by Playwright tests + run: | + mkdir -p backend/target/debug/ + ln -s "$(pwd)/tobira" backend/target/debug/tobira - name: Install Playwright working-directory: frontend run: npm i @playwright/test - name: Install Playwright browsers working-directory: frontend run: npx playwright install --with-deps - - name: Start Tobira for playwright tests - uses: JarvusInnovations/background-action@v1 - with: - run: ./tobira serve --config util/dev-config/config.toml & - wait-on: http://localhost:3080 - name: Run playwright tests working-directory: frontend run: npx playwright test diff --git a/backend/src/db/cmd.rs b/backend/src/db/cmd.rs index bb6e76248..2fdf9f535 100644 --- a/backend/src/db/cmd.rs +++ b/backend/src/db/cmd.rs @@ -252,6 +252,20 @@ async fn clear(db: &mut Db, config: &Config, yes: bool) -> Result<()> { tx.commit().await.context("failed to commit clear transaction")?; info!("Dropped everything inside schema '{schema}' of the database"); + // Try to disconnect all active connections. This is useful as those might + // hold cached statements that are now invalid. + let db_name = &config.db.database; + let sql = format!(" + select pg_terminate_backend(pg_stat_activity.pid) + from pg_stat_activity + where pg_stat_activity.datname = '{db_name}' + and pid <> pg_backend_pid();" + ); + match db.execute(&sql, &[]).await { + Ok(_) => info!("Disconnected all existing connection to this database"), + Err(e) => warn!("Could not disconnect all active connections: {e}"), + } + // ### Step 4: Also clear the search index ### let meili = config.meili.connect().await?; diff --git a/docs/docs/dev/create-release.md b/docs/docs/dev/create-release.md index fc077e8f1..ebd46967d 100644 --- a/docs/docs/dev/create-release.md +++ b/docs/docs/dev/create-release.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # Create a release diff --git a/docs/docs/dev/tests.md b/docs/docs/dev/tests.md new file mode 100644 index 000000000..1dbc0d4e7 --- /dev/null +++ b/docs/docs/dev/tests.md @@ -0,0 +1,38 @@ +--- +sidebar_position: 7 +--- + +# Tests + +Tobira comes with various different automated tests to prevent bugs or unintended changes. +These are all automatically run by our GitHub-based continious integration (CI) for every pull request. +But it is also useful to run the tests locally, for example to add new tests. + + +## Backend & DB tests + +These are tests in `backend/`, written with the built-in Rust test framework, i.e. `#[test]`. +You can find various ones throughout the backend codebase. + +There are some very simple tests that test a small piece of code and are completely isolated. +But there are also "database tests" defined in the backend that make sure the DB with our migrations behaves as expected. +These are defined in `db::tests`. +Each DB test sets up an isolated new database and performs all tests inside so that tests running at the same time do not influence each other. + +You can run all of these tests via **`cargo test`** in the `backend/` folder. +The DB tests require the development PostgreSQL database to be running, so make sure you ran `./x.sh containers start`. + + +## Playwright UI tests + +These tests are "end to end" tests as they test the whole Tobira application in the same way a user would. +We use [Playwright](https://playwright.dev/) to define those tests. +They live in `frontend/tests`. + +In order for these tests to run without interfering with one another, each test is isolated, using its own Tobira binary and database. +A small set of fixed test data can be inserted for the test and a number of small static files (videos, images) are available via a development container. + +Therefore, you also have to run `./x.sh containers start` before running these tests. +Further, you have to run `npm ci` in `frontend/` and build the Tobira binary, both done by `./x.sh start`. +Finally, in some situations, git might not have downloaded the static files properly, which you can fix by manually running `git lfs checkout` or `git lfs fetch`. +With all that done, you can run the tests via `npx playwright test` inside `frontend/`. diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index e3386720c..8fd2bd75f 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -67,6 +67,30 @@ module.exports = { project: false, }, }, + + { + files: ["./tests/**/*"], + rules: { + // Playwright uses fixtures where only destructuring in the arg + // object already has an effect. We just ignore fixtures that + // can be used that way. + "@typescript-eslint/no-unused-vars": ["warn", { + args: "all", + varsIgnorePattern: "^_", + argsIgnorePattern: "^_|standardData|activeSearchIndex", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + ignoreRestSiblings: true, + + }], + + "no-empty-pattern": "off", + + // Playwright tests use tons of async and it's easy to forget + // writing `await`, which leads to confusing test behavior. + "@typescript-eslint/no-floating-promises": "error", + }, + }, ], ignorePatterns: [ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54a49145e..88361bde5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,9 +64,11 @@ "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", "fork-ts-checker-webpack-plugin": "^6.5.3", + "postgres": "^3.4.3", "schema-dts": "^1.1.2", "ts-node": "^10.9.2", - "typescript": "~5.1.6" + "typescript": "~5.1.6", + "wait-port": "^1.1.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -5043,9 +5045,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5458,20 +5460,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7752,20 +7740,6 @@ "node": ">=16" } }, - "node_modules/playwright/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, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -7775,6 +7749,19 @@ "node": ">= 0.4" } }, + "node_modules/postgres": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.3.tgz", + "integrity": "sha512-iHJn4+M9vbTdHSdDzNkC0crHq+1CUdFhx+YqCE+SqWxPjm+Zu63jq7yZborOBF64c8pc58O5uMudyL1FQcHacA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8544,9 +8531,9 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dependencies": { "randombytes": "^2.1.0" } @@ -9452,6 +9439,102 @@ "node": ">=0.10.0" } }, + "node_modules/wait-port": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", + "integrity": "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^9.3.0", + "debug": "^4.3.4" + }, + "bin": { + "wait-port": "bin/wait-port.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/wait-port/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wait-port/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/wait-port/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wait-port/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wait-port/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/wait-port/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wait-port/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0dacd376..21ea3d5ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,8 +80,10 @@ "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", "fork-ts-checker-webpack-plugin": "^6.5.3", + "postgres": "^3.4.3", "schema-dts": "^1.1.2", "ts-node": "^10.9.2", - "typescript": "~5.1.6" + "typescript": "~5.1.6", + "wait-port": "^1.1.0" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 049159b5c..ab1fc8ed8 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,12 +4,10 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests", workers: process.env.CI ? 1 : undefined, - retries: 1, + retries: 0, // TODO reporter: "html", - expect: { timeout: 20 * 1000 }, use: { - baseURL: "http://localhost:3080", headless: true, locale: "en", trace: "retain-on-failure", @@ -32,10 +30,4 @@ export default defineConfig({ use: { ...devices["Desktop Safari"] }, }, ], - - webServer: { - command: "cargo run --manifest-path=../backend/Cargo.toml -- serve", - url: "http://localhost:3080", - reuseExistingServer: true, - }, }); diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index 3e4bdf7ba..225599717 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -502,7 +502,7 @@ manage: den Seiteneinstellungen ändern. title: - content: Hier können Sie Ihren Titel einfügen. + content: Titel text: content: Hier können Sie Ihren Text einfügen. Sie können auch Markdown verwenden. diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 0e80d2637..334b1bed1 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -478,7 +478,7 @@ manage: the page settings. title: - content: You can put your title here. + content: Title text: content: You can add your text content here. You can also use Markdown. diff --git a/frontend/src/routes/manage/Realm/General.tsx b/frontend/src/routes/manage/Realm/General.tsx index f0eaf4b3a..91a11bcf3 100644 --- a/frontend/src/routes/manage/Realm/General.tsx +++ b/frontend/src/routes/manage/Realm/General.tsx @@ -206,6 +206,7 @@ export const NameForm: React.FC = ({ realm }) => { diff --git a/frontend/tests/blocks.spec.ts b/frontend/tests/blocks.spec.ts_ similarity index 100% rename from frontend/tests/blocks.spec.ts rename to frontend/tests/blocks.spec.ts_ diff --git a/frontend/tests/common.ts b/frontend/tests/common.ts deleted file mode 100644 index 4cd9ecabb..000000000 --- a/frontend/tests/common.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Page, expect, test } from "@playwright/test"; - - -export type User = { login: "admin"; displayName: "Administrator" } - | { login: "björk"; displayName: "Prof. Björk Guðmundsdóttir" } - | { login: "jose"; displayName: "José Carreño Quiñones" } - | { login: "morgan"; displayName: "Morgan Yu" } - | { login: "sabine"; displayName: "Sabine Rudolfs" }; - -export const login = async (page: Page, userLogin: string) => { - await expect(async () => { - await navigateTo("~login", page); - await page.getByLabel("User ID").fill(userLogin); - await page.getByLabel("Password").fill("tobira"); - await page.getByRole("button", { name: "Login" }).click(); - await page.waitForURL("/"); - }).toPass({ - intervals: [2000, 5000, 10000], - timeout: 30 * 1000, - }); -}; - - -export const navigateTo = async (path: string, page: Page) => { - await expect(async () => { - await page.goto(path); - await page.waitForURL(path); - }).toPass({ - intervals: [2000, 5000, 10000], - timeout: 30 * 1000, - }); -}; - - -export const deleteRealm = async (page: Page) => { - const deleteButton = page.locator("button:has-text('Delete')"); - - if (!await deleteButton.isVisible()) { - await page.getByRole("link", { name: "Page settings" }).click(); - await expect(deleteButton).toBeVisible(); - } - - await deleteButton.click(); - await expect(deleteButton.nth(1)).toBeVisible(); - - await Promise.all([ - page.waitForResponse(response => - response.url().includes("graphql") - && response.status() === 200), - deleteButton.nth(1).click(), - ]); -}; - - -export const realms = ["User", "Regular"] as const; -export type Realm = typeof realms[number]; - -export const realmSetup = async (page: Page, realm: Realm, user: User, index: number) => { - if (realm === "User") { - await navigateTo(`@${user.login}`, page); - await page.waitForURL(`@${user.login}`); - } else { - await navigateTo("/", page); - } - - await test.step("Create test realms", async () => { - if (realm === "User") { - const createRealmButton = page.getByRole("button", { name: "Create your own page" }); - if (!await createRealmButton.isVisible()) { - await expect(async () => { - await deleteRealm(page); - await page.waitForURL("/"); - await navigateTo(`@${user.login}`, page); - await expect(createRealmButton).toBeVisible(); - }).toPass(); - } - await createRealmButton.click(); - await expect(page.getByText(`Edit page “${user.displayName}”`)).toBeVisible(); - } - - if (realm === "Regular") { - await page.waitForSelector("nav"); - const realms = [`Chicken ${index}`, `Funky Realm ${index}`, `E2E Test Realm ${index}`]; - for (const name of realms) { - const realmLink = page.locator("nav").getByRole("link", { name: name }); - if (await realmLink.isVisible()) { - await realmLink.click(); - await deleteRealm(page); - } - } - - await addSubPage(page, `E2E Test Realm ${index}`); - await expect(page.getByRole("link", { name: `E2E Test Realm ${index}` })).toBeVisible(); - await page.waitForURL(`~manage/realm/content?path=/e2e-test-realm-${index}`); - } - }); -}; - - -export const addSubPage = async (page: Page, name: string) => { - await page.getByRole("link", { name: "Add sub-page" }).first().click(); - await page.getByPlaceholder("Page name").fill(name); - await page.keyboard.press("Tab"); - await page.keyboard.type(name.trim().toLowerCase().replace(/\s+/g, "-")); - await Promise.all([ - page.waitForResponse(response => - response.url().includes("graphql") - && response.status() === 200), - page.getByRole("button", { name: "Create page" }).click(), - ]); -}; - - -export const blocks = ["Series", "Video", "Text", "Title"] as const; -export type Block = typeof blocks[number]; - -export const insertBlock = async (page: Page, block: Block) => { - const addButton = page.getByRole("button", { name: "Insert a new block here" }).first(); - const saveButton = page.getByRole("button", { name: "Save" }); - - await addButton.click(); - await page.getByRole("button", { name: block }).first().click(); - - if (block === "Title") { - await test.step("Title block", async () => { - await page.getByRole("textbox").nth(1).fill("Title"); - await saveButton.click(); - - await expect(page.getByRole("heading", { name: "Title" })).toBeVisible(); - }); - } - if (block === "Text") { - await test.step("Text block", async () => { - const pangram = "The quick brown fox jumps over the lazy dog."; - await page.getByRole("textbox").nth(1).fill(pangram); - await saveButton.click(); - - await expect(page.getByText(pangram)).toBeVisible(); - }); - } - if (block === "Series") { - await test.step("Series block", async () => { - const input = page.locator("div").filter({ hasText: "Select option..." }).nth(1); - const query = "The best open cat videos"; - - await input.type("cat videos"); - await page.getByText("The best open cat videos").click(); - await saveButton.click(); - - await expect(page.getByRole("heading", { name: query })).toBeVisible(); - }); - } - if (block === "Video") { - await test.step("Video block", async () => { - const input = page.locator("div").filter({ hasText: "Select option..." }).nth(1); - const query = "Chicken"; - - await input.type("chicken"); - await page.getByText("Series: The best open cat videos").click(); - await page.keyboard.press("Enter"); - - await expect(page.getByRole("heading", { name: query }).first()).toBeVisible(); - }); - } -}; diff --git a/frontend/tests/empty.spec.ts b/frontend/tests/empty.spec.ts new file mode 100644 index 000000000..245aa77dd --- /dev/null +++ b/frontend/tests/empty.spec.ts @@ -0,0 +1,34 @@ +import { test } from "./util/common"; +import { expect } from "@playwright/test"; +import { USERS } from "./util/user"; + + +test("Empty Tobira", async ({ page, browserName }) => { + test.skip(browserName === "webkit", "Skip safari because it doesn't allow http logins"); + + await page.goto("/"); + + await test.step("Looks empty", async () => { + await expect(page.locator("h1").nth(0)).toContainText("Tobira Videoportal"); + await expect(page.locator("main").nth(0)).toContainText("No pages yet"); + expect(await page.isVisible("text='Login'")).toBe(true); + }); + + await test.step("About page", async () => { + await page.getByText("About Tobira").click(); + await expect(page).toHaveURL("~tobira"); + await expect(page.locator("h2")).toContainText("Version"); + }); + + await test.step("Login works", async () => { + await page.getByRole("link", { name: "Login" }).click(); + await expect(page).toHaveURL("~login"); + + await page.getByLabel("User ID").fill("sabine"); + await page.getByLabel("Password").fill("tobira"); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL("~tobira"); + await expect(page.getByRole("button", { name: USERS.sabine })).toBeVisible(); + }); +}); diff --git a/frontend/tests/fixtures/standard.sql b/frontend/tests/fixtures/standard.sql new file mode 100644 index 000000000..40b78cd5a --- /dev/null +++ b/frontend/tests/fixtures/standard.sql @@ -0,0 +1,354 @@ +-- The fixture used by most tests: a useful data set for many purposes. +-- +-- This can be adjusted for the purpose of writing new tests, as long as it does +-- not affect existing tests. + + +-- ----- Series --------------------------------------------------------------- +insert into series (state, opencast_id, title, description, updated, read_roles, write_roles) +values ('ready', '6d3f7e0c-c18f-4806-acc1-219a02cc7343', 'Fabulous Cats', 'Some amazing cats.', + '2022-05-03 12:20:00+00', '{}', '{"ROLE_USER_SABINE"}'); + +insert into series (state, opencast_id, title, description, updated, read_roles, write_roles) +values ('ready', 'f52ce5fd-fcde-4cd2-9c4b-7e8c7a9ff31d', 'Loyal Dogs', null, + '2023-11-28 08:59:02+00', '{}', '{}'); + +insert into series (state, opencast_id, title, description, updated, read_roles, write_roles) +values ('ready', 'b1cbf499-e168-41fb-8b4e-2cde4835239d', 'Foxes are the very best!!', + 'Cat software running on dog hardware. Well, something like that.\n\nThere are also lots of nice video games about foxes.', + '2008-11-11 18:45:27+00', '{"ROLE_USER"}', '{"ROLE_USER_SABINE","ROLE_USER_JOSE"}'); + +insert into series (state, opencast_id, title, description, updated, read_roles, write_roles) +values ('ready', '4034db58-0233-4926-9a6c-a88c6430cf14', 'Empty series', 'Has no videos :(', + '2018-09-01 14:00:00+00', '{}', '{"ROLE_STAFF"}'); + +insert into series (state, opencast_id, title, description, updated, read_roles, write_roles) +values ('waiting', '2b814c02-c849-4553-b5f5-f4e9e69fd74f', null, null, '-infinity', null, null); + + +-- ----- Videos --------------------------------------------------------------- +-- We have only very few different video files, as we really don't need many for +-- testing. So most videos have a video file that's unrelated to their content. +-- The duration also often does not fit to the file. Tests involving the video +-- player should only use videos where it fits. + +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', 'aaa12fa0-95f8-4722-84d7-4fac5e59a572', 'Video of a Tabby Cat', + 'A nice cat captured with a narrow depth of field.\n\nKindly uploaded by Gustavo Belemmi with a very permissive license.', + '{"Gustavo Belemmi"}', + '{"dcterms": { "source": ["https://www.pexels.com/video/video-of-a-tabby-cat-854982/"] }}', + (select id from series where title = 'Fabulous Cats'), '6d3f7e0c-c18f-4806-acc1-219a02cc7343', + 11520, false, + '2022-03-01 12:00:00+00', '2022-03-01 13:01:00+00', null, null, + '{"ROLE_ANONYMOUS"}', '{ROLE_USER_JOSE}', + 'http://localhost:38456/thumbnail-cat.jpg', + array[ + row('http://localhost:38456/cat-bokeh-no-audio-x264-144p.mp4', + 'presenter/preview', 'video/mp4', '{256, 144}', true), + row('http://localhost:38456/cat-bokeh-no-audio-x264-240p.mp4', + 'presenter/preview', 'video/mp4', '{432, 240}', true) + ]::event_track[], + '{}' +); + +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '2784521d-d10a-4a27-a77c-cd3f557259c2', 'Black Cat (protected)', + 'Secret kitty hihi', + '{"klimkin"}', + '{}', + (select id from series where title = 'Fabulous Cats'), '6d3f7e0c-c18f-4806-acc1-219a02cc7343', + 11520, false, + '2022-03-03 12:00:00+00', '2022-03-03 13:01:00+00', null, null, + '{"ROLE_USER"}', '{ROLE_USER_MORGAN}', + 'http://localhost:38456/thumbnail-cat2.jpg', + array[ + row('http://localhost:38456/cat-black-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true), + row('http://localhost:38456/cat-black-x264-240p.mp4', + 'presentation/preview', 'video/mp4', '{432, 240}', true) + ]::event_track[], + '{}' +); + +-- Dual stream public +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '172d4ec3-4e58-48b4-bdf0-85909a32439d', 'Dual Stream Cats', + null, + '{"Gustavo Belemmi", "klimkin"}', + '{}', + (select id from series where title = 'Fabulous Cats'), '6d3f7e0c-c18f-4806-acc1-219a02cc7343', + 11520, false, + '2022-03-02 12:00:00+00', '2022-03-02 13:01:00+00', null, null, + '{"ROLE_ANONYMOUS"}', '{ROLE_USER_JOSE, ROLE_USER_MORGAN}', + 'http://localhost:38456/thumbnail-cat.jpg', + array[ + row('http://localhost:38456/cat-bokeh-no-audio-x264-144p.mp4', + 'presenter/preview', 'video/mp4', '{256, 144}', true), + row('http://localhost:38456/cat-bokeh-no-audio-x264-240p.mp4', + 'presenter/preview', 'video/mp4', '{432, 240}', true), + row('http://localhost:38456/cat-black-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', false), + row('http://localhost:38456/cat-black-x264-240p.mp4', + 'presentation/preview', 'video/mp4', '{432, 240}', false) + ]::event_track[], + '{}' +); + +-- Planned event +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '9dc41ccb-4a54-498f-98cf-98ca455f708c', 'Far in the Future', + null, + '{"Peter Lustig"}', + '{}', + (select id from series where title = 'Loyal Dogs'), 'f52ce5fd-fcde-4cd2-9c4b-7e8c7a9ff31d', + 2700000, true, + '2023-12-12 12:00:00+00', '2024-01-03 13:01:00+00', '2038-01-03 16:00:00+00', '2038-01-03 16:45:00+00', + '{"ROLE_ANONYMOUS", "ROLE_USER"}', '{ROLE_STUDENTS}', + 'http://localhost:38456/thumbnail-cat2.jpg', + array[ + row('http://localhost:38456/cat-black-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true), + row('http://localhost:38456/cat-black-x264-240p.mp4', + 'presentation/preview', 'video/mp4', '{432, 240}', true) + ]::event_track[], + '{}' +); + +-- Live event +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', 'b5d4533f-0ddd-4dd2-aa64-de24d3b20d72', 'Currently live!!', + null, + '{"Die Maus"}', + '{}', + (select id from series where title = 'Loyal Dogs'), 'f52ce5fd-fcde-4cd2-9c4b-7e8c7a9ff31d', + 2700000, true, + '2008-12-12 12:00:00+00', '2009-01-03 13:01:00+00', '2012-01-03 16:00:00+00', '2038-01-03 16:45:00+00', + '{"ROLE_ANONYMOUS"}', '{}', + 'http://localhost:38456/thumbnail-cat.jpg', + array[ + row('http://localhost:38456/cat-black-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true) + ]::event_track[], + '{}' +); + +-- Past Live event +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', 'd5af7441-e9a3-4a1f-a58d-1116b899c693', 'Past live event', + null, + '{"Hubert"}', + '{}', + (select id from series where title = 'Loyal Dogs'), 'f52ce5fd-fcde-4cd2-9c4b-7e8c7a9ff31d', + 2700000, true, + '2008-12-12 12:00:00+00', '2009-01-03 13:01:00+00', '2012-01-03 16:00:00+00', '2012-01-03 16:45:00+00', + '{"ROLE_ANONYMOUS"}', '{}', + 'http://localhost:38456/thumbnail-cat2.jpg', + array[ + row('http://localhost:38456/cat-black-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true) + ]::event_track[], + '{}' +); + +-- Private video +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '7205a608-08bc-44fc-af8d-65578697c625', 'Very secret private video', + null, + '{"Anon"}', + '{}', + (select id from series where title = 'Loyal Dogs'), 'f52ce5fd-fcde-4cd2-9c4b-7e8c7a9ff31d', + 50000, false, + '2020-07-13 12:00:00+00', '2020-07-13 12:00:00+00', null, null, + '{"ROLE_USER_MORGAN"}', '{"ROLE_USER_MORGAN"}', + 'http://localhost:38456/thumbnail-cat2.jpg', + array[ + row('http://localhost:38456/scifi-tunnel-no-audio-x264-144p.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true) + ]::event_track[], + '{}' +); + +-- Portrait video +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '2e5b56da-695d-4be8-b557-7f0fba51ca24', 'Portait video of a train', + null, + '{"Joachim Rübe"}', + '{}', + (select id from series where title = 'Foxes are the very best!!'), 'b1cbf499-e168-41fb-8b4e-2cde4835239d', + 8000, false, + '2021-07-13 12:00:00+00', '2021-07-13 12:00:00+00', null, null, + '{ROLE_USER}', '{ROLE_USER}', + 'http://localhost:38456/thumbnail-train.jpg', + array[ + row('http://localhost:38456/train-portrait-x264.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true) + ]::event_track[], + '{}' +); + +-- 1h+ video +insert into events (state, opencast_id, title, description, creators, metadata, series, part_of, duration, is_live, + created, updated, start_time, end_time, read_roles, write_roles, thumbnail, tracks, captions) +values ('ready', '1c5d5e89-76b7-4d81-a316-ef62b470e13d', 'Long boy', + null, + '{"Joachim Rübe"}', + '{}', + (select id from series where title = 'Foxes are the very best!!'), 'b1cbf499-e168-41fb-8b4e-2cde4835239d', + 5537000, false, + '2021-07-13 12:00:00+00', '2021-07-13 12:00:00+00', null, null, + '{ROLE_ANONYMOUS}', '{ROLE_STAFF}', + 'http://localhost:38456/thumbnail-train.jpg', + array[ + row('http://localhost:38456/train-portrait-x264.mp4', + 'presentation/preview', 'video/mp4', '{256, 144}', true) + ]::event_track[], + '{}' +); + +-- TODO: +-- Video with subtitles -> array[row('https://...', 'en')]::event_caption[] + + +-- ----- Realms --------------------------------------------------------------- +insert into realms (parent, path_segment, name, child_order) +values (0, 'animals', 'Animal videos', 'by_index'); +insert into realms (parent, path_segment, name, child_order) +values (0, 'support', 'Support page', 'alphabetic:asc'); +insert into realms (parent, path_segment, name, child_order) +values (0, 'love', 'WILL DERIVE', 'alphabetic:desc'); + +insert into realms (parent, path_segment, name, index, child_order) +values ((select id from realms where full_path = '/animals'), 'cats', 'Cats', 2, 'alphabetic:asc'); +insert into realms (parent, path_segment, name, index, child_order) +values ((select id from realms where full_path = '/animals'), 'dogs', 'Dogs', 0, 'alphabetic:asc'); +insert into realms (parent, path_segment, name, index, child_order) +values ((select id from realms where full_path = '/animals'), 'foxes', 'Foxes', 1, 'alphabetic:asc'); + +insert into realms (parent, path_segment, name, child_order) +values ((select id from realms where full_path = '/animals/dogs'), 'small', 'Small ones', 'alphabetic:asc'); +insert into realms (parent, path_segment, name, child_order) +values ((select id from realms where full_path = '/animals/dogs'), 'big', 'Big ones', 'alphabetic:asc'); + +insert into realms (parent, path_segment, name, child_order) +values ((select id from realms where full_path = '/love'), 'kiwi', 'Kiwis', 'alphabetic:asc'); +insert into realms (parent, path_segment, name, child_order) +values ((select id from realms where full_path = '/love'), 'turtles', 'Turtles', 'alphabetic:desc'); + +-- Permissions +update realms set moderator_roles = '{ROLE_STAFF}' where full_path = ''; +update realms set admin_roles = '{ROLE_USER_SABINE}' where full_path = '/love'; +update realms set moderator_roles = '{ROLE_STUDENT}' where full_path = '/love'; +update realms set admin_roles = '{ROLE_USER_MORGAN}' where full_path = '/love/kiwi'; +update realms set moderator_roles = '{ROLE_USER_MORGAN}' where full_path = '/support'; +update realms set admin_roles = '{ROLE_STAFF}' where full_path = '/animals'; +update realms set admin_roles = '{ROLE_USER_BJOERK}' where full_path = '/animals/dogs'; +update realms set moderator_roles = '{ROLE_USER_JOSE}' where full_path = '/animals/dogs'; + + +-- ----- Blocks --------------------------------------------------------------- +-- Homepage +insert into blocks (realm, type, index, text_content) + values (0, 'text', 0, 'Henlo good fren :3'); +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) + values (0, 'series', 1, + (select id from series where title = 'Fabulous Cats'), + 'a_to_z', 'gallery', true, true); +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) + values (0, 'series', 2, + (select id from series where title = 'Loyal Dogs'), + 'old_to_new', 'slider', true, true); +insert into blocks (realm, type, index, text_content) + values (0, 'text', 3, 'But there are _more_ than series. Check **this** out:'); +insert into blocks (realm, type, index, video) + values (0, 'video', 4, (select id from events where title = 'Video of a Tabby Cat')); +insert into blocks (realm, type, index, text_content) + values (0, 'title', 5, 'Credits'); +insert into blocks (realm, type, index, text_content) + values (0, 'text', 6, 'I did it all by myself, hehe.'); + +-- /love page +insert into blocks (realm, type, index, text_content) +values ((select id from realms where full_path = '/love'), + 'text', 0, 'Welcome to this great page! :)'); + +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) +values ((select id from realms where full_path = '/love'), + 'series', 1, + (select id from series where title = 'Fabulous Cats'), + 'new_to_old', 'gallery', true, false); + +update realms +set name = null, name_from_block = ( + select id from blocks where type = 'series' + and realm = (select id from realms where full_path = '/love') +) +where full_path = '/love'; + +-- /support page (markdown test) +insert into blocks (realm, type, index, text_content) +values ((select id from realms where full_path = '/support'), 'text', 0, + 'This page contains various additional test videos. And this text block +also contains tests for various Markdown features. + +> A block quote + +An ordered list: +1. foo +2. bar +3. baz + +You can also have `inline monofont` or even text blocks (without syntax highlighting): + +``` +fn main() { + println!("Ja guten Morgen, Welt"); +} +``` + +That is almost all. Here is a horizontal line: + +--- + +And of course there is **bold** and *cursive* text. +'); + +-- animal pages +insert into blocks (realm, type, index, text_content) +values ((select id from realms where full_path = '/animals'), + 'text', 0, 'We have several different animals. Look at the nav!'); +insert into blocks (realm, type, index, video) + values ((select id from realms where full_path = '/animals'), + 'video', 1, (select id from events where title = 'Far in the Future')); +insert into blocks (realm, type, index, video, show_link) + values ((select id from realms where full_path = '/animals'), + 'video', 2, (select id from events where title = 'Long boy'), false); + +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) +values ((select id from realms where full_path = '/animals/dogs'), + 'series', 0, + (select id from series where title = 'Loyal Dogs'), + 'a_to_z', 'list', false, true); + +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) +values ((select id from realms where full_path = '/animals/cats'), + 'series', 0, + (select id from series where title = 'Fabulous Cats'), + 'z_to_a', 'list', false, true); + +insert into blocks (realm, type, index, series, videolist_order, videolist_layout, show_title, show_metadata) +values ((select id from realms where full_path = '/animals/foxes'), + 'series', 0, + (select id from series where title = 'Foxes are the very best!!'), + 'z_to_a', 'gallery', false, false); + +insert into blocks (realm, type, index, text_content) +values ((select id from realms where full_path = '/animals/dogs/big'), + 'text', 0, 'Big dogs are the better dogs.'); diff --git a/frontend/tests/languageSelection.spec.ts b/frontend/tests/languageSelection.spec.ts index 45a81bbcd..97ead13ae 100644 --- a/frontend/tests/languageSelection.spec.ts +++ b/frontend/tests/languageSelection.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "@playwright/test"; -import { navigateTo } from "./common"; +import { expect } from "@playwright/test"; +import { navigateTo, test } from "./util/common"; test("Language selection", async ({ page }) => { const html = page.locator("html"); @@ -7,7 +7,7 @@ test("Language selection", async ({ page }) => { const german = page.getByRole("checkbox", { name: "Deutsch" }); await navigateTo("/", page); - await page.waitForSelector("nav"); + await page.waitForSelector("h1"); await test.step("Language button is present and opens menu", async () => { await page.getByRole("button", { name: "Language selection" }).click(); diff --git a/frontend/tests/login.spec.ts b/frontend/tests/login.spec.ts index 245f9c548..58370ad65 100644 --- a/frontend/tests/login.spec.ts +++ b/frontend/tests/login.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "@playwright/test"; -import { navigateTo } from "./common"; +import { expect } from "@playwright/test"; +import { navigateTo, test } from "./util/common"; test("Login", async ({ page, baseURL, browserName }) => { test.skip(browserName === "webkit", "Skip safari because it doesn't allow http logins"); diff --git a/frontend/tests/realms.spec.ts b/frontend/tests/realms.spec.ts index c325e834f..c7280c809 100644 --- a/frontend/tests/realms.spec.ts +++ b/frontend/tests/realms.spec.ts @@ -1,50 +1,51 @@ -import { test, expect, Page } from "@playwright/test"; -import { - addSubPage, - deleteRealm, - insertBlock, - login, - navigateTo, - realmSetup, - realms, - User, -} from "./common"; - - -for (const realm of realms) { - test.skip(`${realm} realm editing`, async ({ page, browserName }) => { - test.skip(browserName === "webkit", "Skip safari because it doesn't allow http logins"); +import { expect } from "@playwright/test"; +import { test, realms } from "./util/common"; +import { USERS, login } from "./util/user"; +import { createUserRealm, addSubPage, addBlock } from "./util/realm"; - const user: User = browserName === "chromium" - ? { login: "admin", displayName: "Administrator" } - : { login: "sabine", displayName: "Sabine Rudolfs" }; - const realmIndex = browserName === "chromium" ? 2 : 3; - const settingsLink = page.getByRole("link", { name: "Page settings" }); - const saveButton = page.getByRole("button", { name: "Save" }); - const subPages = ["Alchemy", "Barnacles", "Cheese"]; - const nav = page.locator("nav").first().getByRole("listitem"); +for (const realmType of realms) { + test(`${realmType} realm moderator editing`, async ({ + page, browserName, standardData, activeSearchIndex, + }) => { + test.skip(browserName === "webkit", "Skip safari because it doesn't allow http logins"); + const userid = realmType === "User" ? "jose" : "sabine"; + const parentPageName = realmType === "User" ? USERS[userid] : "Support page"; await test.step("Setup", async () => { - await login(page, user.login); - await realmSetup(page, realm, user, realmIndex); - await insertBlock(page, "Video"); + await page.goto("/"); + await login(page, userid); + + // Go to a non-root realm + if (realmType === "Regular") { + await page.locator("nav").getByRole("link", { name: parentPageName }).click(); + await expect(page).toHaveURL("/support"); + } + + // Create user realm + if (realmType === "User") { + await test.step("Create new user realm", async () => { + await createUserRealm(page, userid); + }); + await page.locator("nav").getByRole("link", { name: parentPageName }).click(); + await expect(page).toHaveURL(`/@${userid}`); + } }); + const nav = page.locator("nav").first().getByRole("listitem"); + const subPages = ["Alchemy", "Barnacles", "Cheese"]; await test.step("Sub-pages can be added", async () => { - const target = realm === "User" ? `@${user.login}` : `e2e-test-realm-${realmIndex}`; for (const subPage of subPages) { - await expect(async () => { - await navigateTo(target, page); - await addSubPage(page, subPage); - await page.waitForSelector(`h1:has-text("Edit page “${subPage}”")`); - }).toPass(); + await addSubPage(page, subPage); + await page.locator("nav > ol").getByRole("link", { name: parentPageName }).click(); + await page.getByRole("heading", { name: parentPageName, level: 1 }).waitFor(); } - await navigateTo(target, page); await expect(nav).toHaveText(subPages); }); + const saveButton = page.getByRole("button", { name: "Save" }); + const settingsLink = page.getByRole("link", { name: "Page settings" }); await test.step("Order of sub-pages can be changed", async () => { await test.step("Sort alphabetically descending", async () => { await settingsLink.click(); @@ -83,35 +84,50 @@ for (const realm of realms) { }); await test.step("Name can be changed", async () => { + const derivedOption = page.getByText("Derive name from video or series"); + + await test.step("Derived name not possible without blocks", async () => { + await derivedOption.click(); + await expect(page.getByText("There are no linkable video/series on this page.")) + .toBeVisible(); + }); + await test.step("Derived name", async () => { - await page.locator("label:has-text('Derive name from video or series')").click(); - await page.getByRole("combobox").selectOption("Video: Chicken"); + await page.getByRole("link", { name: "Edit page content" }).click(); + await addBlock(page, 0, { type: "video", query: "long" }); + + await settingsLink.click(); + await derivedOption.click(); + await derivedOption + .locator("..") + .locator("..") + .getByRole("combobox") + .selectOption("Video: Long boy"); await saveButton.first().click(); - await expect( - page.getByRole("heading", { name: "Settings of page “Chicken”" }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Settings of page “Long boy”" })) + .toBeVisible(); + await expect(page.locator("nav > ol").getByRole("link", { name: "Long boy" })) + .toBeVisible(); }); await test.step("Custom name", async () => { - await page.locator("label:has-text('Name directly')").click(); - await page.locator("#rename-field").fill(`Funky Realm ${realmIndex}`); + await page.reload(); // To clear the name input field + const name = "Yummy Kale"; + await page.getByText("Name directly").click(); + await page.getByPlaceholder("Page name").fill(name); await saveButton.first().click(); - await expect( - page.getByRole( - "heading", { name: `Settings of page “Funky Realm ${realmIndex}”` } - ), - ).toBeVisible(); - - await page.locator("#rename-field").fill(`E2E Test Realm ${realmIndex}`); - await saveButton.first().click(); - await expect( - page.getByRole("heading", { name: `E2E Test Realm ${realmIndex}` }) - ).toBeVisible(); + await expect(page.getByRole("heading", { name: `Settings of page “${name}”` })) + .toBeVisible(); }); }); + }); +} +// eslint-disable-next-line capitalized-comments +// eslint-disable-next-line multiline-comment-style +/* if (realm === "Regular") { await test.step("Path changing", async () => { await test.step("Path can be changed", async () => { @@ -159,6 +175,4 @@ for (const realm of realms) { ).not.toBeVisible(); } }); - }); -} - +*/ diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts index 342fca107..fe8f90fbe 100644 --- a/frontend/tests/search.spec.ts +++ b/frontend/tests/search.spec.ts @@ -1,12 +1,12 @@ -import { test, expect } from "@playwright/test"; -import { navigateTo } from "./common"; +import { expect } from "@playwright/test"; +import { test, navigateTo } from "./util/common"; -test("Search", async ({ page }) => { +test("Search", async ({ page, standardData, activeSearchIndex }) => { await navigateTo("/", page); await page.waitForSelector("nav"); const searchField = page.getByPlaceholder("Search"); - const query = "video"; + const query = "cat"; await test.step("Should be focusable by keyboard shortcut", async () => { await page.keyboard.press("s"); @@ -14,19 +14,51 @@ test("Search", async ({ page }) => { }); await test.step("Should allow search queries to be executed", async () => { - const url = `~search?q=${query}`; await searchField.fill(query); + await expect(page).toHaveURL(`~search?q=${query}`); + }); + + await test.step("Should show breadcrumbs", async () => { + await expect(page.getByText("Search results for “cat” (4 hits)")).toBeVisible(); + }); + + for (const videoTitle of ["Video of a Tabby Cat", "Dual Stream Cats"]) { + await test.step(`Should show video '${videoTitle}'`, async () => { + await expect(page.getByRole("img", { name: `Thumbnail for “${videoTitle}”` })) + .toBeVisible(); + const title = page.getByRole("heading", { name: videoTitle }); + // We need `force` to allow clicking the overlay. + await title.click({ force: true }); + expect(page.url().startsWith("/!v/")); + await page.goBack(); + }); + } - await expect(page).toHaveURL(url); + await test.step("Series links should work", async () => { + const eventSeriesLink = page.getByRole("link", { name: "Fabulous Cats" }); + await expect(eventSeriesLink).toHaveCount(2); + await eventSeriesLink.first().click(); + expect(page.url().startsWith("/!s/")); + await page.goBack(); }); - await test.step("Should show search results", async () => { - await expect(page.getByText("Search results")).toBeVisible(); - const results = page - .locator("li") - .filter({ hasText: "video" }) - .locator("a"); - await results.nth(1).waitFor(); - expect(await results.count()).toBeGreaterThan(0); + await test.step("Should show realm 'Cats'", async () => { + const realm = page.getByRole("heading", { name: "Cats", exact: true }); + await expect(realm).toBeVisible(); + await realm.click({ force: true }); + await expect(page).toHaveURL("/animals/cats"); + await page.goBack(); + }); + + await test.step("Should show realm 'Fabulous Cats'", async () => { + const realm = page.getByRole("heading", { name: "Fabulous Cats" }); + await expect(realm).toBeVisible(); + await realm.click({ force: true }); + await expect(page).toHaveURL("/love"); + await page.goBack(); }); }); + +// TODO: +// - login and see protected videos +// - search for video only included in one page & having correct link diff --git a/frontend/tests/tsconfig.json b/frontend/tests/tsconfig.json new file mode 100644 index 000000000..e83848744 --- /dev/null +++ b/frontend/tests/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "moduleResolution": "Node" + } +} diff --git a/frontend/tests/util/common.ts b/frontend/tests/util/common.ts new file mode 100644 index 000000000..f59f841f2 --- /dev/null +++ b/frontend/tests/util/common.ts @@ -0,0 +1,96 @@ +import { Page, expect } from "@playwright/test"; +import { test as base } from "./data"; + +// Reexport such that everything can be imported from this file. +export const test = base; + + + + +export const navigateTo = async (path: string, page: Page) => { + await expect(async () => { + await page.goto(path); + await page.waitForURL(path); + }).toPass({ + intervals: [2000, 5000, 10000], + timeout: 30 * 1000, + }); +}; + + +export const deleteRealm = async (page: Page) => { + const deleteButton = page.locator("button:has-text('Delete')"); + + if (!await deleteButton.isVisible()) { + await page.getByRole("link", { name: "Page settings" }).click(); + await expect(deleteButton).toBeVisible(); + } + + await deleteButton.click(); + await expect(deleteButton.nth(1)).toBeVisible(); + + await Promise.all([ + page.waitForResponse(response => + response.url().includes("graphql") + && response.status() === 200), + deleteButton.nth(1).click(), + ]); +}; + + +export const realms = ["User", "Regular"] as const; +export type Realm = typeof realms[number]; + + +export const blocks = ["Series", "Video", "Text", "Title"] as const; +export type Block = typeof blocks[number]; + +export const insertBlock = async (page: Page, block: Block) => { + const addButton = page.getByRole("button", { name: "Insert a new block here" }).first(); + const saveButton = page.getByRole("button", { name: "Save" }); + + await addButton.click(); + await page.getByRole("button", { name: block }).first().click(); + + if (block === "Title") { + await test.step("Title block", async () => { + await page.getByRole("textbox").nth(1).fill("Title"); + await saveButton.click(); + + await expect(page.getByRole("heading", { name: "Title" })).toBeVisible(); + }); + } + if (block === "Text") { + await test.step("Text block", async () => { + const pangram = "The quick brown fox jumps over the lazy dog."; + await page.getByRole("textbox").nth(1).fill(pangram); + await saveButton.click(); + + await expect(page.getByText(pangram)).toBeVisible(); + }); + } + if (block === "Series") { + await test.step("Series block", async () => { + const input = page.locator("div").filter({ hasText: "Select option..." }).nth(1); + const query = "The best open cat videos"; + + await input.type("cat videos"); + await page.getByText("The best open cat videos").click(); + await saveButton.click(); + + await expect(page.getByRole("heading", { name: query })).toBeVisible(); + }); + } + if (block === "Video") { + await test.step("Video block", async () => { + const input = page.locator("div").filter({ hasText: "Select option..." }).nth(1); + const query = "Chicken"; + + await input.type("chicken"); + await page.getByText("Series: The best open cat videos").click(); + await page.keyboard.press("Enter"); + + await expect(page.getByRole("heading", { name: query }).first()).toBeVisible(); + }); + } +}; diff --git a/frontend/tests/util/data.ts b/frontend/tests/util/data.ts new file mode 100644 index 000000000..a9e1647dd --- /dev/null +++ b/frontend/tests/util/data.ts @@ -0,0 +1,26 @@ +// This file defines a bunch of fixtures to insert a set of data into the DB +// before the test. + +import * as fs from "fs/promises"; +import { test as base } from "./isolation"; +import postgres from "postgres"; + +export type CustomTestFixtures = { + /** The standard data set suitable for most tests. */ + standardData: StandardData; +}; + +export type StandardData = Record; + +export const test = base.extend({ + standardData: async ({ tobiraProcess }, use, workerInfo) => { + // Create temporary database for this Tobira process + const sql = postgres(`postgres://tobira:tobira@127.0.0.1/${tobiraProcess.dbName}`, { + onnotice: () => {}, + }); + const code = await fs.readFile(`${workerInfo.config.rootDir}/fixtures/standard.sql`); + await sql.unsafe(code.toString()); + await sql.end(); + await use({}); + }, +}); diff --git a/frontend/tests/util/isolation.ts b/frontend/tests/util/isolation.ts new file mode 100644 index 000000000..d25abfc72 --- /dev/null +++ b/frontend/tests/util/isolation.ts @@ -0,0 +1,159 @@ +// This file defines "fixtures" that provide a perfectly isolated testing +// environment. For each worker process, one Tobira instance is started with +// its own database. The database is cleared after every test. + +import * as fs from "fs/promises"; +import * as childProcess from "child_process"; +import { test as base } from "@playwright/test"; +import postgres from "postgres"; +import waitPort from "wait-port"; + + +export type CustomWorkerFixtures = { + tobiraProcess: TobiraProcess; +}; + +export type CustomTestFixtures = { + tobiraReset: Record; + activeSearchIndex: ActiveSearchIndex; +}; + +export type TobiraProcess = { + port: number; + workerDir: string; + configPath: string; + binaryPath: string; + dbName: string; + index: number; +}; + +export type ActiveSearchIndex = Record; + +export const test = base.extend({ + // This fixture starts a new completely isolated Tobira process (with its + // own DB) for each Playwright worker process. + tobiraProcess: [async ({}, use, workerInfo) => { + // Create temporary folder + const index = workerInfo.parallelIndex; + const outDir = `${workerInfo.config.rootDir}/../test-results/`; + const workerDir = `${outDir}/_tobira/process${index}`; + const rootPath = `${workerInfo.config.rootDir}/../../`; + const configPath = `${workerDir}/config.toml`; + await fs.mkdir(workerDir, { recursive: true }); + + // Write config file for this test runner + const port = 3100 + index; + const dbName = `tobira_ui_test_${index}`; + const config = tobiraConfig({ port, dbName, index, rootPath }); + await fs.writeFile(configPath, config, { encoding: "utf8" }); + + // Create temporary database for this Tobira process + const sql = postgres("postgres://tobira:tobira@127.0.0.1/tobira", { + onnotice: () => {}, + }); + await sql.unsafe(`drop database if exists ${dbName}`); + await sql.unsafe(`create database ${dbName}`); + + // Start Tobira + const binaryPath = `${rootPath}/backend/target/debug/tobira`; + const tobiraProcess = childProcess.spawn( + binaryPath, + ["serve", "--config", configPath], + // { stdio: "inherit" } + ); + await waitPort({ port, interval: 10, output: "silent" }); + + + // Use fixture + await use({ port, workerDir, configPath, binaryPath, dbName, index }); + + + // Cleanup + tobiraProcess.kill(); + await sql.unsafe(`drop database if exists ${dbName}`); + await fs.rm(workerDir, { recursive: true }); + }, { scope: "worker", auto: true }], + + // We set the base URL for all tests here, which depends on the port. + baseURL: async ({ tobiraProcess }, use) => { + await use(`http://localhost:${tobiraProcess.port}`); + }, + + // This resets the Tobira DB after every test that modifies any data. + tobiraReset: [async ({ tobiraProcess }, use) => { + await use({}); + await runTobiraCommand(tobiraProcess, ["db", "reset", "--yes-absolutely-clear-db"]); + }, { auto: true }], + + activeSearchIndex: async ({ tobiraProcess }, use) => { + await runTobiraCommand(tobiraProcess, ["search-index", "update"]); + await use({}); + await runTobiraCommand(tobiraProcess, + ["search-index", "clear", "--yes-absolutely-clear-index"]); + }, +}); + +const runTobiraCommand = async (tobira: TobiraProcess, args: string[]) => { + await new Promise(resolve => { + args.push("-c"); + args.push(tobira.configPath); + const p = childProcess.spawn(tobira.binaryPath, args); + p.on("close", resolve); + }); +}; + +// TODO: DB +const tobiraConfig = ({ index, port, dbName, rootPath }: { + index: number; + port: number; + dbName: string; + rootPath: string; +}) => ` + [general] + site_title.en = "Tobira Videoportal" + tobira_url = "http://localhost:${port}" + users_searchable = true + + [http] + port = ${port} + + [db] + database = "${dbName}" + user = "tobira" + password = "tobira" + tls_mode = "off" + + [meili] + index_prefix = "tobira_ui_test_${index}" + key = "tobira" + + [log] + level = "debug" + + [auth] + source = "tobira-session" + session.from_login_credentials = "login-callback:http://localhost:3091" + trusted_external_key = "tobira" + pre_auth_external_links = true + + [auth.jwt] + signing_algorithm = "ES256" + + [opencast] + host = "https://dummy.invalid" # Not used in UI tests + + [sync] + user = "admin" + password = "opencast" + + [theme] + logo.large.path = "${rootPath}/util/dev-config/logo-large.svg" + logo.large.resolution = [425, 182] + logo.large_dark.path = "${rootPath}/util/dev-config/logo-large-dark.svg" + logo.large_dark.resolution = [425, 182] + logo.small.path = "${rootPath}/util/dev-config/logo-small.svg" + logo.small.resolution = [212, 182] + logo.small_dark.path = "${rootPath}/util/dev-config/logo-small.svg" + logo.small_dark.resolution = [212, 182] + favicon = "${rootPath}/util/dev-config/favicon.svg" +`; diff --git a/frontend/tests/util/realm.ts b/frontend/tests/util/realm.ts new file mode 100644 index 000000000..2a81bbbe9 --- /dev/null +++ b/frontend/tests/util/realm.ts @@ -0,0 +1,100 @@ +import { Page, expect } from "@playwright/test"; +import { USERS, UserId } from "./user"; + +/** + * Creates the user realm for the given user. + * + * - Pre-conditions: User is already logged in, the user has no user realm yet. + * - Post-conditions: User realm created, is on the page that Tobira forwards to + * immediately after creating the realm. + */ +export const createUserRealm = async (page: Page, userid: UserId) => { + await page.getByRole("button", { name: USERS[userid] }).click(); + await page.getByRole("link", { name: "My page" }).click(); + await expect(page).toHaveURL(`/@${userid}`); + await page.getByRole("button", { name: "Create your own page" }).click(); +}; + + +/** + * Creates a sub-realm on the page you are currently on. + * + * - Pre-conditions: logged in & on a realm page with privileges to create a sub-realm. + * - Post-conditions: Realm created, on the page "Edit realm contents" page. + */ +export const addSubPage = async (page: Page, name: string, pathSegment?: string) => { + await page.getByRole("link", { name: "Add subpage" }).first().click(); + await page.getByPlaceholder("Page name").fill(name); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await expect(page.getByLabel("Path segment")).toBeFocused(); + await page.keyboard.type(pathSegment ?? name.trim().toLowerCase().replace(/\s+/g, "-")); + await page.getByRole("button", { name: "Create page" }).click(); + await expect(page.getByRole("heading", { name: `Edit page “${name}”`, level: 1 })) + .toBeVisible(); +}; + +type Block = + | { + type: "title"; + text: string; + } + | { + type: "text"; + text: string; + } + | { + type: "video"; + query: string; + showTitle?: boolean; + showLink?: boolean; + }; + +/** + * Adds the specified block. For series and video blocks, picks the first result + * returned for `query`. `query` must be a substring of said result's title. + * + * - Pre-conditions: logged in, already on "edit realm contents" page. + * - Post-conditions: added block, still on "edit realm contents" page. + */ +export const addBlock = async (page: Page, pos: number, block: Block) => { + await expect(page.getByRole("heading", { name: "Edit page" })).toBeVisible(); + + const addButtons = page.getByRole("button", { name: "Insert a new block here" }); + const saveButton = page.getByRole("button", { name: "Save" }); + const numSlots = await addButtons.count(); + + await addButtons.nth(pos).click(); + await page.getByRole("button", { name: block.type }).click(); + + switch (block.type) { + case "title": { + await page.getByPlaceholder("Title").fill(block.text); + await saveButton.click(); + break; + } + case "text": { + await page.getByPlaceholder("You can add your text content here").fill(block.text); + await saveButton.click(); + break; + } + case "video": { + const input = page.getByRole("combobox"); + await input.pressSequentially(block.query); + await page.getByRole("img", { name: block.query }).click(); + + const titleCheckbox = page.getByLabel("Show title"); + await expect(titleCheckbox).toBeChecked(); + await titleCheckbox.setChecked(block.showTitle ?? true); + const linkCheckbox = page.getByLabel("Show link to video page"); + await expect(linkCheckbox).toBeChecked(); + await linkCheckbox.setChecked(block.showTitle ?? true); + + await saveButton.click(); + break; + } + } + + await expect(addButtons).toHaveCount(numSlots + 1); + await expect(saveButton).toBeHidden(); +}; diff --git a/frontend/tests/util/user.ts b/frontend/tests/util/user.ts new file mode 100644 index 000000000..92780fd18 --- /dev/null +++ b/frontend/tests/util/user.ts @@ -0,0 +1,28 @@ +import { Page, expect } from "@playwright/test"; + + +export const USERS = { + "admin": "Administrator", + "björk": "Prof. Björk Guðmundsdóttir", + "jose": "José Carreño Quiñones", + "morgan": "Morgan Yu", + "sabine": "Sabine Rudolfs", +}; + +export type UserId = keyof typeof USERS; + +/** + * Logs in as the given user. + * + * - Pre-conditions: Not logged in. + * - Post-conditions: Logged in & on the previous page. + */ +export const login = async (page: Page, username: UserId) => { + const prevUrl = page.url(); + await page.getByRole("link", { name: "Login" }).click({ }); + await expect(page).toHaveURL("~login"); + await page.getByLabel("User ID").fill(username); + await page.getByLabel("Password").fill("tobira"); + await page.getByRole("button", { name: "Login" }).click(); + await expect(page).toHaveURL(prevUrl); +}; diff --git a/frontend/tests/videoPage.spec.ts b/frontend/tests/videoPage.spec.ts index 881a9560d..7e96e6d8d 100644 --- a/frontend/tests/videoPage.spec.ts +++ b/frontend/tests/videoPage.spec.ts @@ -1,15 +1,14 @@ -import { test, expect } from "@playwright/test"; -import { navigateTo } from "./common"; +import { expect } from "@playwright/test"; +import { navigateTo, test } from "./util/common"; -test("Video page", async ({ page }) => { +test("Video page", async ({ page, standardData, browserName }) => { const metadatumLocator = (datum: "duration" | "part of series") => page.locator(`dd:right-of(dt:has-text("${datum}"))`).first(); await test.step("Setup", async () => { await navigateTo("/", page); - // Click first non-live video. - await page.getByRole("link", { name: "Thumbnail" }).first().click(); + await page.getByRole("img", { name: "Video of a Tabby Cat" }).first().click(); await page.waitForSelector("nav"); }); @@ -21,8 +20,8 @@ test("Video page", async ({ page }) => { }); await test.step("Video is playable", async () => { + test.skip(browserName === "webkit", "Paella does not load for Safari"); // TODO await player.click(); - await expect(page.locator(".progress-indicator-container")).toBeVisible(); }); }); @@ -44,8 +43,8 @@ test("Video page", async ({ page }) => { await test.step("Metadata fields are present", async () => { // We can at least test the presence of "Duration" and "Part of series" values // as those should always be shown for these tests. - await expect(metadatumLocator("part of series")).not.toBeEmpty(); - await expect(metadatumLocator("duration")).not.toBeEmpty(); + await expect(metadatumLocator("part of series")).toHaveText("Fabulous Cats"); + await expect(metadatumLocator("duration")).toHaveText("0:12"); }); await test.step("Series block", async () => { @@ -57,22 +56,18 @@ test("Video page", async ({ page }) => { ).toBeVisible(); }); - // Only do this step if block contains at least one other event. - const siblingEvent = page.getByRole("link", { name: "Thumbnail" }).first(); - - if (await siblingEvent.isVisible()) { - await test.step("Block contains sibling event tile", async () => { - await test.step("Tile links to event", async () => { - const siblingId = await siblingEvent.getAttribute("href") as string; - await siblingEvent.click(); - await expect(page).toHaveURL(siblingId); - }); + await test.step("Block contains sibling event tile", async () => { + const siblingEvent = page.getByRole("link", { name: "Thumbnail" }).first(); + await test.step("Tile links to event", async () => { + const siblingId = await siblingEvent.getAttribute("href") as string; + await siblingEvent.click(); + await expect(page).toHaveURL(siblingId); + }); - await test.step("Event is part of the correct series", async () => { - const siblingSeries = await metadatumLocator("part of series").textContent(); - expect(siblingSeries).toBe(series); - }); + await test.step("Event is part of the correct series", async () => { + const siblingSeries = await metadatumLocator("part of series").textContent(); + expect(siblingSeries).toBe(series); }); - } + }); }); }); diff --git a/util/containers/docker-compose.yml b/util/containers/docker-compose.yml index 9df0e5b69..fb2ff526d 100644 --- a/util/containers/docker-compose.yml +++ b/util/containers/docker-compose.yml @@ -46,6 +46,15 @@ services: - MEILI_NO_ANALYTICS=true - MEILI_MASTER_KEY=tobira + # A static file server for UI tests + tobira-ui-test-files: + image: nginx + ports: + - "38456:38456" + volumes: + - ./ui-test-files-nginx.conf:/etc/nginx/conf.d/default.conf + - ./test-files:/www/data + volumes: tobira-dev-postgres: diff --git a/util/containers/test-files/cat-black-x264-144p.mp4 b/util/containers/test-files/cat-black-x264-144p.mp4 new file mode 100644 index 000000000..6fe369423 --- /dev/null +++ b/util/containers/test-files/cat-black-x264-144p.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fdaed16535376ef8f9b204b25e17d5048297f31a40b3b975840141ac5e53682 +size 90268 diff --git a/util/containers/test-files/cat-black-x264-240p.mp4 b/util/containers/test-files/cat-black-x264-240p.mp4 new file mode 100644 index 000000000..43019ddf8 --- /dev/null +++ b/util/containers/test-files/cat-black-x264-240p.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d2ed01f4c8fd1f106f739dd8362e604360874113b8613f9c8fab34694bf9957 +size 191725 diff --git a/util/containers/test-files/cat-bokeh-no-audio-x264-144p.mp4 b/util/containers/test-files/cat-bokeh-no-audio-x264-144p.mp4 new file mode 100644 index 000000000..072058a30 --- /dev/null +++ b/util/containers/test-files/cat-bokeh-no-audio-x264-144p.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c39f07d8acf4b46e6ee7c393a37ba27720bd7e1902d6082d59c5b81e073c7db +size 146638 diff --git a/util/containers/test-files/cat-bokeh-no-audio-x264-240p.mp4 b/util/containers/test-files/cat-bokeh-no-audio-x264-240p.mp4 new file mode 100644 index 000000000..4ce7ef80f --- /dev/null +++ b/util/containers/test-files/cat-bokeh-no-audio-x264-240p.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:336b90b1ddb6b5c7fc8b224f625928e79a29c407e22ec32905d6e4cd002c60a7 +size 379165 diff --git a/util/containers/test-files/scifi-tunnel-no-audio-x264-144p.mp4 b/util/containers/test-files/scifi-tunnel-no-audio-x264-144p.mp4 new file mode 100644 index 000000000..ca0ee7586 --- /dev/null +++ b/util/containers/test-files/scifi-tunnel-no-audio-x264-144p.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c25b2e7f26042c7cae292c748915b49f5ecd31da5b2d440a7e83576f6bac22cc +size 257657 diff --git a/util/containers/test-files/thumbnail-cat.jpg b/util/containers/test-files/thumbnail-cat.jpg new file mode 100644 index 000000000..70c150b74 --- /dev/null +++ b/util/containers/test-files/thumbnail-cat.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f18c46b45c2a8376af1ef74012d37d3ca34d2f589e6f816086a53efdf811f6c5 +size 30524 diff --git a/util/containers/test-files/thumbnail-cat2.jpg b/util/containers/test-files/thumbnail-cat2.jpg new file mode 100644 index 000000000..79b51268e --- /dev/null +++ b/util/containers/test-files/thumbnail-cat2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bb0ec0d4d2e151186027d9fcc62de99328bc8398b04ca83203a935a076dce52 +size 9436 diff --git a/util/containers/test-files/thumbnail-train.jpg b/util/containers/test-files/thumbnail-train.jpg new file mode 100644 index 000000000..45e5f4c4a --- /dev/null +++ b/util/containers/test-files/thumbnail-train.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:998eda1fb53f37ab41de19a1086efa6078eb90925c15adfcbadd0504d91d0f2c +size 18371 diff --git a/util/containers/test-files/train-portrait-x264.mp4 b/util/containers/test-files/train-portrait-x264.mp4 new file mode 100644 index 000000000..5f6c514cb --- /dev/null +++ b/util/containers/test-files/train-portrait-x264.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ab88ec2fd06ffb2671889914fd59142f9d2c2309a1db141265696686aa516d9 +size 92661 diff --git a/util/containers/ui-test-files-nginx.conf b/util/containers/ui-test-files-nginx.conf new file mode 100644 index 000000000..fce622543 --- /dev/null +++ b/util/containers/ui-test-files-nginx.conf @@ -0,0 +1,18 @@ +server { + listen 38456; + server_name local_opencast_with_cors; + + # Basic open CORS for all domains. Don't use this in production! + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Methods 'GET, POST, PUT, OPTIONS' always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Headers 'Origin,Content-Type,Accept,Authorization' always; + + # Always respond with 200 to OPTIONS requests as browsers do not accept + # non-200 responses to CORS preflight requests. + if ($request_method = OPTIONS) { + return 200; + } + + root /www/data; +}