diff --git a/app/.gitignore b/app/.gitignore index 74f7dc3..863eab7 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,3 +1,3 @@ -test-results playwright-report +test-results bknd.config.* \ No newline at end of file diff --git a/app/__test__/vitest/base.vi-test.ts b/app/__test__/vitest/base.vi-test.ts new file mode 100644 index 0000000..d3fef16 --- /dev/null +++ b/app/__test__/vitest/base.vi-test.ts @@ -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); + }); +}); diff --git a/app/__test__/vitest/setup.ts b/app/__test__/vitest/setup.ts new file mode 100644 index 0000000..da27771 --- /dev/null +++ b/app/__test__/vitest/setup.ts @@ -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(); +}); diff --git a/app/e2e/assets/image.jpg b/app/e2e/assets/image.jpg new file mode 100644 index 0000000..3f48d4b Binary files /dev/null and b/app/e2e/assets/image.jpg differ diff --git a/app/e2e/base.e2e-spec.ts b/app/e2e/base.e2e-spec.ts new file mode 100644 index 0000000..8ed1469 --- /dev/null +++ b/app/e2e/base.e2e-spec.ts @@ -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(); +}); diff --git a/app/e2e/inc/adapters.ts b/app/e2e/inc/adapters.ts new file mode 100644 index 0000000..2a8eff0 --- /dev/null +++ b/app/e2e/inc/adapters.ts @@ -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; +} \ No newline at end of file diff --git a/app/e2e/media.e2e-spec.ts b/app/e2e/media.e2e-spec.ts new file mode 100644 index 0000000..72f4e09 --- /dev/null +++ b/app/e2e/media.e2e-spec.ts @@ -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"); +}); diff --git a/app/package.json b/app/package.json index 511b5a6..a75a4d0 100644 --- a/app/package.json +++ b/app/package.json @@ -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": { diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 0000000..72096dc --- /dev/null +++ b/app/playwright.config.ts @@ -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, +}); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index f3eb1b9..88782f5 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -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 = { export type ConfigUpdateResponse = | ConfigUpdate | { 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) { diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx index 0cafa13..b80f006 100644 --- a/app/src/ui/components/buttons/Button.tsx +++ b/app/src/ui/components/buttons/Button.tsx @@ -36,6 +36,7 @@ export type BaseProps = { size?: keyof typeof sizes; variant?: keyof typeof styles; labelClassName?: string; + "data-testid"?: string; }; const Base = ({ diff --git a/app/src/ui/lib/config.ts b/app/src/ui/lib/config.ts new file mode 100644 index 0000000..eb4bbcc --- /dev/null +++ b/app/src/ui/lib/config.ts @@ -0,0 +1,8 @@ +export const config = {}; + +export const testIds = { + data: { + btnCreateEntity: "data-btns-create-entity", + }, + media: {}, +}; diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index aa831ee..a72be12 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -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, }} /> diff --git a/app/src/ui/routes/media/media.settings.tsx b/app/src/ui/routes/media/media.settings.tsx index 8df5452..810a6da 100644 --- a/app/src/ui/routes/media/media.settings.tsx +++ b/app/src/ui/routes/media/media.settings.tsx @@ -139,6 +139,7 @@ function Adapters() {