implement/init e2e tests (#135)

* init e2e

* updated/moved vitest, finished merge

* fix bun picking up e2e tests

* e2e: overwrite webserver config with env

* e2e: added adapter configs

* e2e: replaced image
This commit is contained in:
dswbx
2025-04-03 11:08:16 +02:00
committed by GitHub
parent 0b41aa5a2d
commit fa6c7acaf5
16 changed files with 365 additions and 78 deletions

2
app/.gitignore vendored
View File

@@ -1,3 +1,3 @@
test-results
playwright-report
test-results
bknd.config.*

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
describe("Example Test Suite", () => {
it("should pass basic arithmetic", () => {
expect(1 + 1).toBe(2);
});
it("should handle async operations", async () => {
const result = await Promise.resolve(42);
expect(result).toBe(42);
});
});

View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Automatically cleanup after each test
afterEach(() => {
cleanup();
});

BIN
app/e2e/assets/image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

22
app/e2e/base.e2e-spec.ts Normal file
View File

@@ -0,0 +1,22 @@
// @ts-check
import { test, expect } from "@playwright/test";
import { testIds } from "../src/ui/lib/config";
test("start page has expected title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/BKND/);
});
test("start page has expected heading", async ({ page }) => {
await page.goto("/");
// Example of checking if a heading with "No entity selected" exists and is visible
const heading = page.getByRole("heading", { name: /No entity selected/i });
await expect(heading).toBeVisible();
});
test("modal opens on button click", async ({ page }) => {
await page.goto("/");
await page.getByTestId(testIds.data.btnCreateEntity).click();
await expect(page.getByRole("dialog")).toBeVisible();
});

23
app/e2e/inc/adapters.ts Normal file
View File

@@ -0,0 +1,23 @@
const adapter = process.env.TEST_ADAPTER;
const default_config = {
media_adapter: "local"
} as const;
const configs = {
cloudflare: {
media_adapter: "r2"
}
}
export function getAdapterConfig(): typeof default_config {
if (adapter) {
if (!configs[adapter]) {
throw new Error(`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`);
}
return configs[adapter] as typeof default_config;
}
return default_config;
}

55
app/e2e/media.e2e-spec.ts Normal file
View File

@@ -0,0 +1,55 @@
// @ts-check
import { test, expect } from "@playwright/test";
import { testIds } from "../src/ui/lib/config";
import type { SchemaResponse } from "../src/modules/server/SystemController";
import { getAdapterConfig } from "./inc/adapters";
// Annotate entire file as serial.
test.describe.configure({ mode: "serial" });
const adapterConfig = getAdapterConfig();
test("can enable media", async ({ page }) => {
await page.goto("/media/settings");
// enable
const enableToggle = page.locator("css=button#enabled");
if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
await expect(enableToggle).toBeVisible();
await enableToggle.click();
await expect(enableToggle).toHaveAttribute("aria-checked", "true");
// select local
const adapterChoice = page.locator(`css=button#adapter-${adapterConfig.media_adapter}`);
await expect(adapterChoice).toBeVisible();
await adapterChoice.click();
// save
const saveBtn = page.getByRole("button", { name: /Update/i });
await expect(saveBtn).toBeVisible();
// intercept network request, wait for it to finish and get the response
const [request] = await Promise.all([
page.waitForRequest((request) => request.url().includes("api/system/schema")),
saveBtn.click(),
]);
const response = await request.response();
expect(response?.status(), "fresh config 200").toBe(200);
const body = (await response?.json()) as SchemaResponse;
expect(body.config.media.enabled, "media is enabled").toBe(true);
expect(body.config.media.adapter.type, "correct adapter").toBe(adapterConfig.media_adapter);
}
});
test("can upload a file", async ({ page }) => {
await page.goto("/media");
// check any text to contain "Upload files"
await expect(page.getByText(/Upload files/i)).toBeVisible();
// upload a file from disk
// Start waiting for file chooser before clicking. Note no await.
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Upload file").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles("./e2e/assets/image.jpg");
});

View File

@@ -15,12 +15,6 @@
},
"scripts": {
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
@@ -33,7 +27,20 @@
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md"
"postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report"
},
"license": "FSL-1.1-MIT",
"dependencies": {
@@ -76,18 +83,23 @@
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
"@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.9",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
@@ -109,6 +121,7 @@
"tsx": "^4.19.3",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
},
"optionalDependencies": {

41
app/playwright.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, devices } from "@playwright/test";
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
export default defineConfig({
testMatch: "**/*.e2e-spec.ts",
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: baseUrl,
trace: "on-first-retry",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
/* {
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
}, */
],
webServer: autoStart
? {
command: startCommand,
url: baseUrl,
reuseExistingServer: !process.env.CI,
}
: undefined,
});

View File

@@ -18,6 +18,7 @@ import { Controller } from "modules/Controller";
import {
MODULE_NAMES,
type ModuleConfigs,
type ModuleSchemas,
type ModuleKey,
getDefaultConfig,
} from "modules/ModuleManager";
@@ -36,6 +37,12 @@ export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
| ConfigUpdate<Key>
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
export type SchemaResponse = {
version: string;
schema: ModuleSchemas;
config: ModuleConfigs;
permissions: string[];
};
export class SystemController extends Controller {
constructor(private readonly app: App) {

View File

@@ -36,6 +36,7 @@ export type BaseProps = {
size?: keyof typeof sizes;
variant?: keyof typeof styles;
labelClassName?: string;
"data-testid"?: string;
};
const Base = ({

8
app/src/ui/lib/config.ts Normal file
View File

@@ -0,0 +1,8 @@
export const config = {};
export const testIds = {
data: {
btnCreateEntity: "data-btns-create-entity",
},
media: {},
};

View File

@@ -21,6 +21,7 @@ import { Link, isLinkActive } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
import { testIds } from "ui/lib/config";
export function DataRoot({ children }) {
// @todo: settings routes should be centralized
@@ -269,6 +270,7 @@ export function DataEmpty() {
}}
primary={{
children: "Create entity",
"data-testid": testIds.data.btnCreateEntity,
onClick: $data.modals.createEntity,
}}
/>

View File

@@ -139,6 +139,7 @@ function Adapters() {
<Button
key={i}
onClick={() => ctx.select(i)}
id={`adapter-${schema.properties.type.const}`}
variant={ctx.selected === i ? "primary" : "outline"}
className={twMerge(
"flex flex-row items-center justify-center gap-3 border",

18
app/vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./__test__/vitest/setup.ts"],
include: ["**/*.vi-test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.config.ts"],
},
},
});