e2e: added script to auto test adapters

This commit is contained in:
dswbx
2025-04-03 16:40:51 +02:00
parent fa6c7acaf5
commit a12d4e13d0
16 changed files with 295 additions and 24 deletions

206
app/e2e/adapters.ts Normal file
View File

@@ -0,0 +1,206 @@
import { $ } from "bun";
import path from "node:path";
import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
async function run(
cmd: string[] | string,
opts: Bun.SpawnOptions.OptionsObject & {},
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
): Promise<{ proc: Bun.Subprocess; data: any }> {
return new Promise((resolve, reject) => {
const proc = Bun.spawn(Array.isArray(cmd) ? cmd : cmd.split(" "), {
...opts,
stdout: "pipe",
stderr: "pipe",
});
// Read from stdout
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
// Function to read chunks
let resolveCalled = false;
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
if (!resolveCalled) {
console.log(c.dim(text.replace(/\n$/, "")));
}
onChunk(
text,
(data) => {
resolve({ proc, data });
resolveCalled = true;
},
reject,
);
}
} catch (err) {
reject(err);
}
})();
proc.exited.then((code) => {
if (code !== 0 && code !== 130) {
throw new Error(`Process exited with code ${code}`);
}
});
});
}
const adapters = {
node: {
dir: path.join(basePath, "examples/node"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
},
start: async function () {
return await run(
"npm run start",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /running on (http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
bun: {
dir: path.join(basePath, "examples/bun"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
},
start: async function () {
return await run(
"npm run start",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /running on (http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
cloudflare: {
dir: path.join(basePath, "examples/cloudflare-worker"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .wrangler node_modules/.cache node_modules/.mf`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Ready on (http:\/\/.*)/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
"react-router": {
dir: path.join(basePath, "examples/react-router"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .react-router data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\//;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
nextjs: {
dir: path.join(basePath, "examples/nextjs"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .nextjs data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
astro: {
dir: path.join(basePath, "examples/astro"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .astro data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\//;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
} as const;
for (const [name, config] of Object.entries(adapters)) {
console.log("adapter", c.cyan(name));
await config.clean();
const { proc, data } = await config.start();
console.log("proc:", proc.pid, "data:", c.cyan(data));
//proc.kill();process.exit(0);
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
console.log("DONE!");
while (!proc.killed) {
proc.kill("SIGINT");
await Bun.sleep(250);
console.log("Waiting for process to exit...");
}
//process.exit(0);
}

View File

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

View File

@@ -1,22 +1,43 @@
const adapter = process.env.TEST_ADAPTER; const adapter = process.env.TEST_ADAPTER;
const default_config = { const default_config = {
media_adapter: "local" media_adapter: "local",
base_path: "",
} as const; } as const;
const configs = { const configs = {
cloudflare: { cloudflare: {
media_adapter: "r2" media_adapter: "r2",
} },
} "react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
};
export function getAdapterConfig(): typeof default_config { export function getAdapterConfig(): typeof default_config {
if (adapter) { if (adapter) {
if (!configs[adapter]) { if (!configs[adapter]) {
throw new Error(`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`); console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
} }
return configs[adapter] as typeof default_config;
} }
return default_config; return default_config;

View File

@@ -7,10 +7,10 @@ import { getAdapterConfig } from "./inc/adapters";
// Annotate entire file as serial. // Annotate entire file as serial.
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
const adapterConfig = getAdapterConfig(); const config = getAdapterConfig();
test("can enable media", async ({ page }) => { test("can enable media", async ({ page }) => {
await page.goto("/media/settings"); await page.goto(`${config.base_path}/media/settings`);
// enable // enable
const enableToggle = page.locator("css=button#enabled"); const enableToggle = page.locator("css=button#enabled");
@@ -20,7 +20,7 @@ test("can enable media", async ({ page }) => {
await expect(enableToggle).toHaveAttribute("aria-checked", "true"); await expect(enableToggle).toHaveAttribute("aria-checked", "true");
// select local // select local
const adapterChoice = page.locator(`css=button#adapter-${adapterConfig.media_adapter}`); const adapterChoice = page.locator(`css=button#adapter-${config.media_adapter}`);
await expect(adapterChoice).toBeVisible(); await expect(adapterChoice).toBeVisible();
await adapterChoice.click(); await adapterChoice.click();
@@ -37,12 +37,12 @@ test("can enable media", async ({ page }) => {
expect(response?.status(), "fresh config 200").toBe(200); expect(response?.status(), "fresh config 200").toBe(200);
const body = (await response?.json()) as SchemaResponse; const body = (await response?.json()) as SchemaResponse;
expect(body.config.media.enabled, "media is enabled").toBe(true); expect(body.config.media.enabled, "media is enabled").toBe(true);
expect(body.config.media.adapter.type, "correct adapter").toBe(adapterConfig.media_adapter); expect(body.config.media.adapter?.type, "correct adapter").toBe(config.media_adapter);
} }
}); });
test("can upload a file", async ({ page }) => { test("can upload a file", async ({ page }) => {
await page.goto("/media"); await page.goto(`${config.base_path}/media`);
// check any text to contain "Upload files" // check any text to contain "Upload files"
await expect(page.getByText(/Upload files/i)).toBeVisible(); await expect(page.getByText(/Upload files/i)).toBeVisible();

View File

@@ -1,5 +1,5 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"], "include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./node_modules", "./__test__"] "exclude": ["./node_modules", "./__test__", "./e2e"]
} }

View File

@@ -33,6 +33,13 @@
"bknd": ["./src/*"] "bknd": ["./src/*"]
} }
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx", "vite.dev.ts", "build.ts", "__test__"], "include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"vite.dev.ts",
"build.ts",
"__test__",
"e2e/**/*.ts"
],
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"] "exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
} }

View File

@@ -23,3 +23,6 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
*.db *.db
# Public uploads
/public/uploads/*

View File

@@ -42,7 +42,7 @@ export default {
media: { media: {
enabled: true, enabled: true,
adapter: local({ adapter: local({
path: "./public", path: "./public/uploads",
}), }),
}, },
}, },

View File

@@ -173,3 +173,6 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
*.db
uploads/*

View File

@@ -8,6 +8,15 @@ const config: BunBkndConfig = {
connection: { connection: {
url: "file:data.db", url: "file:data.db",
}, },
initialConfig: {
media: {
enabled: true,
adapter: {
type: "local",
config: { path: "./uploads" },
},
},
},
}; };
serve(config); serve(config);

View File

@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Public uploads
/public/uploads/*

View File

@@ -51,7 +51,7 @@ export default {
media: { media: {
enabled: true, enabled: true,
adapter: local({ adapter: local({
path: "./public", path: "./public/uploads",
}), }),
}, },
}, },

2
examples/node/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.db
uploads/*

View File

@@ -7,8 +7,19 @@ import { serve } from "bknd/adapter/node";
/** @type {import("bknd/adapter/node").NodeBkndConfig} */ /** @type {import("bknd/adapter/node").NodeBkndConfig} */
const config = { const config = {
connection: { connection: {
url: "file:data.db" url: "file:data.db",
} },
initialConfig: {
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./uploads",
},
},
},
},
}; };
serve(config); serve(config);

View File

@@ -7,3 +7,6 @@
# React Router # React Router
/.react-router/ /.react-router/
/build/ /build/
# Public uploads
/public/uploads/*

View File

@@ -41,7 +41,7 @@ export default {
media: { media: {
enabled: true, enabled: true,
adapter: local({ adapter: local({
path: "./public", path: "./public/uploads",
}), }),
}, },
}, },