diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f032d0..6fbec3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,12 +15,20 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: latest + bun-version: "1.2.5" - name: Install dependencies working-directory: ./app run: bun install - - name: Run tests + - name: Build working-directory: ./app - run: bun run test \ No newline at end of file + run: bun run build:ci + + - name: Run Bun tests + working-directory: ./app + run: bun run test:bun + + - name: Run Node tests + working-directory: ./app + run: npm run test:node \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..8415a0f --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +playwright-report +test-results +bknd.config.* +__test__/helper.d.ts \ No newline at end of file diff --git a/app/__test__/_assets/image.jpg b/app/__test__/_assets/image.jpg new file mode 100644 index 0000000..0f7890d Binary files /dev/null and b/app/__test__/_assets/image.jpg differ diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts new file mode 100644 index 0000000..734fb08 --- /dev/null +++ b/app/__test__/adapter/adapter.test.ts @@ -0,0 +1,62 @@ +import { expect, describe, it, beforeAll, afterAll } from "bun:test"; +import * as adapter from "adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("adapter", () => { + it("makes config", () => { + expect(adapter.makeConfig({})).toEqual({}); + expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({}); + + // merges everything returned from `app` with the config + expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({ + env: { TEST: "test" }, + } as any); + }); + + it("reuses apps correctly", async () => { + const id = crypto.randomUUID(); + + const first = await adapter.createAdapterApp( + { + initialConfig: { server: { cors: { origin: "random" } } }, + }, + undefined, + { id }, + ); + const second = await adapter.createAdapterApp(); + const third = await adapter.createAdapterApp(undefined, undefined, { id }); + + await first.build(); + await second.build(); + await third.build(); + + expect(first.toJSON().server.cors.origin).toEqual("random"); + expect(first).toBe(third); + expect(first).not.toBe(second); + expect(second).not.toBe(third); + expect(second.toJSON().server.cors.origin).toEqual("*"); + + // recreate the first one + const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true }); + await first2.build(); + expect(first2).not.toBe(first); + expect(first2).not.toBe(third); + expect(first2).not.toBe(second); + expect(first2.toJSON().server.cors.origin).toEqual("*"); + }); + + adapterTestSuite(bunTestRunner, { + makeApp: adapter.createFrameworkApp, + label: "framework app", + }); + + adapterTestSuite(bunTestRunner, { + makeApp: adapter.createRuntimeApp, + label: "runtime app", + }); +}); diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index c6bb3df..51786ca 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -5,7 +5,8 @@ import { DataApi } from "../../src/data/api/DataApi"; import { DataController } from "../../src/data/api/DataController"; import { dataConfigSchema } from "../../src/data/data-schema"; import * as proto from "../../src/data/prototype"; -import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper"; +import { schemaToEm } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -64,6 +65,15 @@ describe("DataApi", () => { const res = await req; expect(res.data).toEqual(payload as any); } + + { + // make sure sort is working + const req = await api.readMany("posts", { + select: ["title"], + sort: "-id", + }); + expect(req.data).toEqual(payload.reverse() as any); + } }); it("updates many", async () => { diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index c5e9794..860258a 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { ModuleBuildContext } from "../../src"; -import { type App, createApp } from "../../src/App"; +import { App, createApp } from "../../src/App"; import * as proto from "../../src/data/prototype"; describe("App", () => { @@ -51,4 +51,87 @@ describe("App", () => { expect(todos[0]?.title).toBe("ctx"); expect(todos[1]?.title).toBe("api"); }); + + test("lifecycle events are triggered", async () => { + const firstBoot = mock(() => null); + const configUpdate = mock(() => null); + const appBuilt = mock(() => null); + const appRequest = mock(() => null); + const beforeResponse = mock(() => null); + + const app = createApp(); + + app.emgr.onEvent( + App.Events.AppFirstBoot, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppFirstBoot); + expect(event.params.app.version()).toBe(app.version()); + firstBoot(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppBuiltEvent, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppBuiltEvent); + expect(event.params.app.version()).toBe(app.version()); + appBuilt(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + () => { + configUpdate(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppRequest, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppRequest); + expect(event.params.app.version()).toBe(app.version()); + expect(event.params.request).toBeInstanceOf(Request); + appRequest(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppBeforeResponse, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppBeforeResponse); + expect(event.params.app.version()).toBe(app.version()); + expect(event.params.response).toBeInstanceOf(Response); + beforeResponse(); + }, + "sync", + ); + + await app.build(); + expect(firstBoot).toHaveBeenCalled(); + expect(appBuilt).toHaveBeenCalled(); + //expect(configUpdate).toHaveBeenCalled(); + expect(appRequest).not.toHaveBeenCalled(); + expect(beforeResponse).not.toHaveBeenCalled(); + }); + + test("emgr exec modes", async () => { + const called = mock(() => null); + const app = createApp({ + options: { + asyncEventsMode: "sync", + }, + }); + + // register async listener + app.emgr.onEvent(App.Events.AppFirstBoot, async () => { + called(); + }); + + await app.build(); + await app.server.request(new Request("http://localhost")); + + // expect async listeners to be executed sync after request + expect(called).toHaveBeenCalled(); + }); }); diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index 7b69376..b27aa51 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import * as proto from "../../src/data/prototype"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; describe("repros", async () => { /** diff --git a/app/__test__/auth/strategies/OAuthStrategy.spec.ts b/app/__test__/auth/strategies/OAuthStrategy.spec.ts index 93ceae0..becd783 100644 --- a/app/__test__/auth/strategies/OAuthStrategy.spec.ts +++ b/app/__test__/auth/strategies/OAuthStrategy.spec.ts @@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies"; const ALL_TESTS = !!process.env.ALL_TESTS; +// @todo: add mock response describe("OAuthStrategy", async () => { - const strategy = new OAuthStrategy({ + return; + /*const strategy = new OAuthStrategy({ type: "oidc", client: { client_id: process.env.OAUTH_CLIENT_ID!, @@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => { const server = Bun.serve({ fetch: async (req) => { + console.log("req", req.method, req.url); const url = new URL(req.url); if (url.pathname === "/auth/google/callback") { console.log("req", req); @@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => { console.log("request", request); await new Promise((resolve) => setTimeout(resolve, 100000)); - }); + });*/ }); diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 995ebfa..3d8b981 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -70,6 +70,9 @@ describe("EventManager", async () => { new SpecialEvent({ foo: "bar" }); new InformationalEvent(); + // execute asyncs + await emgr.executeAsyncs(); + expect(call).toHaveBeenCalledTimes(2); expect(delayed).toHaveBeenCalled(); }); @@ -80,15 +83,11 @@ describe("EventManager", async () => { call(); return Promise.all(p); }; - const emgr = new EventManager( - { InformationalEvent }, - { - asyncExecutor, - }, - ); + const emgr = new EventManager({ InformationalEvent }); emgr.onEvent(InformationalEvent, async () => {}); await emgr.emit(new InformationalEvent()); + await emgr.executeAsyncs(asyncExecutor); expect(call).toHaveBeenCalled(); }); @@ -125,6 +124,9 @@ describe("EventManager", async () => { const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); expect(e2.returned).toBe(true); expect(e2.params.foo).toBe("bar-1-0"); + + await emgr.executeAsyncs(); + expect(onInvalidReturn).toHaveBeenCalled(); expect(asyncEventCallback).toHaveBeenCalled(); }); diff --git a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts b/app/__test__/core/cache/CloudflareKvCache.native-spec.ts deleted file mode 100644 index d5f0812..0000000 --- a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as assert from "node:assert/strict"; -import { createWriteStream } from "node:fs"; -import { after, beforeEach, describe, test } from "node:test"; -import { Miniflare } from "miniflare"; -import { - CloudflareKVCacheItem, - CloudflareKVCachePool, -} from "../../../src/core/cache/adapters/CloudflareKvCache"; -import { runTests } from "./cache-test-suite"; - -// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 -console.log = async (message: any) => { - const tty = createWriteStream("/dev/tty"); - const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2); - return tty.write(`${msg}\n`); -}; - -describe("CloudflareKv", async () => { - let mf: Miniflare; - runTests({ - createCache: async () => { - if (mf) { - await mf.dispose(); - } - - mf = new Miniflare({ - modules: true, - script: "export default { async fetch() { return new Response(null); } }", - kvNamespaces: ["TEST"], - }); - const kv = await mf.getKVNamespace("TEST"); - return new CloudflareKVCachePool(kv as any); - }, - createItem: (key, value) => new CloudflareKVCacheItem(key, value), - tester: { - test, - beforeEach, - expect: (actual?: any) => { - return { - toBe(expected: any) { - assert.equal(actual, expected); - }, - toEqual(expected: any) { - assert.deepEqual(actual, expected); - }, - toBeUndefined() { - assert.equal(actual, undefined); - }, - }; - }, - }, - }); - - after(async () => { - await mf?.dispose(); - }); -}); diff --git a/app/__test__/core/cache/MemoryCache.spec.ts b/app/__test__/core/cache/MemoryCache.spec.ts deleted file mode 100644 index d78a5d1..0000000 --- a/app/__test__/core/cache/MemoryCache.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache"; -import { runTests } from "./cache-test-suite"; - -describe("MemoryCache", () => { - runTests({ - createCache: async () => new MemoryCache(), - createItem: (key, value) => new MemoryCacheItem(key, value), - tester: { - test, - beforeEach, - expect, - }, - }); -}); diff --git a/app/__test__/core/cache/cache-test-suite.ts b/app/__test__/core/cache/cache-test-suite.ts deleted file mode 100644 index 251dfde..0000000 --- a/app/__test__/core/cache/cache-test-suite.ts +++ /dev/null @@ -1,84 +0,0 @@ -//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test"; -import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface"; - -export type TestOptions = { - createCache: () => Promise; - createItem: (key: string, value: any) => ICacheItem; - tester: { - test: (name: string, fn: () => Promise) => void; - beforeEach: (fn: () => Promise) => void; - expect: (actual?: any) => { - toBe(expected: any): void; - toEqual(expected: any): void; - toBeUndefined(): void; - }; - }; -}; - -export function runTests({ createCache, createItem, tester }: TestOptions) { - let cache: ICachePool; - const { test, beforeEach, expect } = tester; - - beforeEach(async () => { - cache = await createCache(); - }); - - test("getItem returns correct item", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - const retrievedItem = await cache.get("key1"); - expect(retrievedItem.value()).toEqual(item.value()); - }); - - test("getItem returns new item when key does not exist", async () => { - const retrievedItem = await cache.get("key1"); - expect(retrievedItem.key()).toEqual("key1"); - expect(retrievedItem.value()).toBeUndefined(); - }); - - test("getItems returns correct items", async () => { - const item1 = createItem("key1", "value1"); - const item2 = createItem("key2", "value2"); - await cache.save(item1); - await cache.save(item2); - const retrievedItems = await cache.getMany(["key1", "key2"]); - expect(retrievedItems.get("key1")?.value()).toEqual(item1.value()); - expect(retrievedItems.get("key2")?.value()).toEqual(item2.value()); - }); - - test("hasItem returns true when item exists and is a hit", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - expect(await cache.has("key1")).toBe(true); - }); - - test("clear and deleteItem correctly clear the cache and delete items", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - - if (cache.supports().clear) { - await cache.clear(); - } else { - await cache.delete("key1"); - } - - expect(await cache.has("key1")).toBe(false); - }); - - test("save correctly saves items to the cache", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - expect(await cache.has("key1")).toBe(true); - }); - - test("putItem correctly puts items in the cache ", async () => { - await cache.put("key1", "value1", { ttl: 60 }); - const item = await cache.get("key1"); - expect(item.value()).toEqual("value1"); - expect(item.hit()).toBe(true); - }); - - /*test("commit returns true", async () => { - expect(await cache.commit()).toBe(true); - });*/ -} diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index db3c967..15428bf 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils"; +import { Perf, ucFirst } from "../../src/core/utils"; import * as utils from "../../src/core/utils"; +import { assetsPath } from "../helper"; async function wait(ms: number) { return new Promise((resolve) => { @@ -75,57 +76,6 @@ describe("Core Utils", async () => { const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); - - describe("guards", () => { - const types = { - blob: new Blob(), - file: new File([""], "file.txt"), - stream: new ReadableStream(), - arrayBuffer: new ArrayBuffer(10), - arrayBufferView: new Uint8Array(new ArrayBuffer(10)), - }; - - const fns = [ - [utils.isReadableStream, "stream"], - [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isArrayBuffer, "arrayBuffer"], - [utils.isArrayBufferView, "arrayBufferView"], - ] as const; - - const additional = [0, 0.0, "", null, undefined, {}, []]; - - for (const [fn, type, _to_test] of fns) { - test(`is${ucFirst(type)}`, () => { - const to_test = _to_test ?? (Object.keys(types) as string[]); - for (const key of to_test) { - const value = types[key as keyof typeof types]; - const result = fn(value); - expect(result).toBe(key === type); - } - - for (const value of additional) { - const result = fn(value); - expect(result).toBe(false); - } - }); - } - }); - - test("getContentName", () => { - const name = "test.json"; - const text = "attachment; filename=" + name; - const headers = new Headers({ - "Content-Disposition": text, - }); - const request = new Request("http://example.com", { - headers, - }); - - expect(utils.getContentName(text)).toBe(name); - expect(utils.getContentName(headers)).toBe(name); - expect(utils.getContentName(request)).toBe(name); - }); }); describe("perf", async () => { @@ -246,6 +196,76 @@ describe("Core Utils", async () => { }); }); + describe("file", async () => { + describe("type guards", () => { + const types = { + blob: new Blob(), + file: new File([""], "file.txt"), + stream: new ReadableStream(), + arrayBuffer: new ArrayBuffer(10), + arrayBufferView: new Uint8Array(new ArrayBuffer(10)), + }; + + const fns = [ + [utils.isReadableStream, "stream"], + [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isArrayBuffer, "arrayBuffer"], + [utils.isArrayBufferView, "arrayBufferView"], + ] as const; + + const additional = [0, 0.0, "", null, undefined, {}, []]; + + for (const [fn, type, _to_test] of fns) { + test(`is${ucFirst(type)}`, () => { + const to_test = _to_test ?? (Object.keys(types) as string[]); + for (const key of to_test) { + const value = types[key as keyof typeof types]; + const result = fn(value); + expect(result).toBe(key === type); + } + + for (const value of additional) { + const result = fn(value); + expect(result).toBe(false); + } + }); + } + }); + + test("getContentName", () => { + const name = "test.json"; + const text = "attachment; filename=" + name; + const headers = new Headers({ + "Content-Disposition": text, + }); + const request = new Request("http://example.com", { + headers, + }); + + expect(utils.getContentName(text)).toBe(name); + expect(utils.getContentName(headers)).toBe(name); + expect(utils.getContentName(request)).toBe(name); + }); + + test.only("detectImageDimensions", async () => { + // wrong + // @ts-expect-error + expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); + + // successful ones + const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any; + expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({ + width: 362, + height: 387, + }); + expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({ + width: 453, + height: 512, + }); + }); + }); + describe("dates", () => { test.only("formats local time", () => { expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 4b3bee7..7110956 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => { test("events were fired", async () => { const { data } = await mutator.insertOne({ label: "test" }); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue(); await mutator.updateOne(data.id, { label: "test2" }); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue(); await mutator.deleteOne(data.id); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); }); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index 2a42b9e..f50ca83 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, test } from "bun:test"; import type { Kysely, Transaction } from "kysely"; -import { Perf } from "../../../src/core/utils"; +import { Perf } from "core/utils"; import { Entity, EntityManager, @@ -8,7 +8,10 @@ import { ManyToOneRelation, RepositoryEvents, TextField, -} from "../../../src/data"; + entity as $entity, + text as $text, + em as $em, +} from "data"; import { getDummyConnection } from "../helper"; type E = Kysely | Transaction; @@ -177,6 +180,47 @@ describe("[Repository]", async () => { const res5 = await em.repository(items).exists({}); expect(res5.exists).toBe(true); }); + + test("option: silent", async () => { + const em = $em({ + items: $entity("items", { + label: $text(), + }), + }).proto.withConnection(getDummyConnection().dummyConnection); + + // should throw because table doesn't exist + expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/); + // should silently return empty result + expect( + em + .repo("items", { silent: true }) + .findMany({}) + .then((r) => r.data), + ).resolves.toEqual([]); + }); + + test("option: includeCounts", async () => { + const em = $em({ + items: $entity("items", { + label: $text(), + }), + }).proto.withConnection(getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + expect( + em + .repo("items") + .findMany({}) + .then((r) => [r.meta.count, r.meta.total]), + ).resolves.toEqual([0, 0]); + + expect( + em + .repo("items", { includeCounts: false }) + .findMany({}) + .then((r) => [r.meta.count, r.meta.total]), + ).resolves.toEqual([undefined, undefined]); + }); }); describe("[data] Repository (Events)", async () => { @@ -198,22 +242,27 @@ describe("[data] Repository (Events)", async () => { }); test("events were fired", async () => { - await em.repository(items).findId(1); + const repo = em.repository(items); + await repo.findId(1); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findOne({ id: 1 }); + await repo.findOne({ id: 1 }); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findMany({ where: { id: 1 } }); + await repo.findMany({ where: { id: 1 } }); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findManyByReference(1, "categories"); + await repo.findManyByReference(1, "categories"); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); diff --git a/app/__test__/data/specs/fields/BooleanField.spec.ts b/app/__test__/data/specs/fields/BooleanField.spec.ts index 7ed5036..a061e1f 100644 --- a/app/__test__/data/specs/fields/BooleanField.spec.ts +++ b/app/__test__/data/specs/fields/BooleanField.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { BooleanField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] BooleanField", async () => { - runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" }); + fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" }); test("transformRetrieve", async () => { const field = new BooleanField("test"); diff --git a/app/__test__/data/specs/fields/DateField.spec.ts b/app/__test__/data/specs/fields/DateField.spec.ts index 3e29bf0..d578843 100644 --- a/app/__test__/data/specs/fields/DateField.spec.ts +++ b/app/__test__/data/specs/fields/DateField.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { DateField } from "../../../../src/data"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] DateField", async () => { - runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" }); + fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" }); // @todo: add datefield tests test("week", async () => { diff --git a/app/__test__/data/specs/fields/EnumField.spec.ts b/app/__test__/data/specs/fields/EnumField.spec.ts index 2187bee..066dd88 100644 --- a/app/__test__/data/specs/fields/EnumField.spec.ts +++ b/app/__test__/data/specs/fields/EnumField.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "bun:test"; import { EnumField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; function options(strings: string[]) { return { type: "strings", values: strings }; } describe("[data] EnumField", async () => { - runBaseFieldTests( + fieldTestSuite( + { expect, test }, + // @ts-ignore EnumField, { defaultValue: "a", schemaType: "text" }, { options: options(["a", "b", "c"]) }, @@ -15,11 +17,13 @@ describe("[data] EnumField", async () => { test("yields if default value is not a valid option", async () => { expect( + // @ts-ignore () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }), ).toThrow(); }); test("transformPersist (config)", async () => { + // @ts-ignore const field = new EnumField("test", { options: options(["a", "b", "c"]) }); expect(transformPersist(field, null)).resolves.toBeUndefined(); @@ -29,6 +33,7 @@ describe("[data] EnumField", async () => { test("transformRetrieve", async () => { const field = new EnumField("test", { + // @ts-ignore options: options(["a", "b", "c"]), default_value: "a", required: true, diff --git a/app/__test__/data/specs/fields/Field.spec.ts b/app/__test__/data/specs/fields/Field.spec.ts index 82ba9de..d5fec44 100644 --- a/app/__test__/data/specs/fields/Field.spec.ts +++ b/app/__test__/data/specs/fields/Field.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Default, stripMark } from "../../../../src/core/utils"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] Field", async () => { class FieldSpec extends Field { @@ -19,7 +19,7 @@ describe("[data] Field", async () => { }); }); - runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); + fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" }); test("default config", async () => { const config = Default(baseFieldConfigSchema, {}); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index 8f1590c..0dd656c 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -1,19 +1,13 @@ import { describe, expect, test } from "bun:test"; import { Type } from "../../../../src/core/utils"; -import { - Entity, - EntityIndex, - type EntityManager, - Field, - type SchemaResponse, -} from "../../../../src/data"; +import { Entity, EntityIndex, Field } from "../../../../src/data"; class TestField extends Field { protected getSchema(): any { return Type.Any(); } - schema(em: EntityManager): SchemaResponse { + override schema() { return undefined as any; } } diff --git a/app/__test__/data/specs/fields/JsonField.spec.ts b/app/__test__/data/specs/fields/JsonField.spec.ts index dff15a1..0bc0d3b 100644 --- a/app/__test__/data/specs/fields/JsonField.spec.ts +++ b/app/__test__/data/specs/fields/JsonField.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { JsonField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] JsonField", async () => { const field = new JsonField("test"); - runBaseFieldTests(JsonField, { + fieldTestSuite({ expect, test }, JsonField, { defaultValue: { a: 1 }, sampleValues: ["string", { test: 1 }, 1], schemaType: "text", diff --git a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts index f9f2f54..7770098 100644 --- a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts +++ b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import { JsonSchemaField } from "../../../../src/data"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] JsonSchemaField", async () => { - runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" }); + // @ts-ignore + fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" }); // @todo: add JsonSchemaField tests }); diff --git a/app/__test__/data/specs/fields/NumberField.spec.ts b/app/__test__/data/specs/fields/NumberField.spec.ts index 6708449..e46c075 100644 --- a/app/__test__/data/specs/fields/NumberField.spec.ts +++ b/app/__test__/data/specs/fields/NumberField.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { NumberField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] NumberField", async () => { test("transformPersist (config)", async () => { @@ -15,5 +15,5 @@ describe("[data] NumberField", async () => { expect(transformPersist(field2, 10000)).resolves.toBe(10000); }); - runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" }); + fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" }); }); diff --git a/app/__test__/data/specs/fields/TextField.spec.ts b/app/__test__/data/specs/fields/TextField.spec.ts index fe83767..47d1bc3 100644 --- a/app/__test__/data/specs/fields/TextField.spec.ts +++ b/app/__test__/data/specs/fields/TextField.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { TextField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] TextField", async () => { test("transformPersist (config)", async () => { @@ -11,5 +11,5 @@ describe("[data] TextField", async () => { expect(transformPersist(field, "abc")).resolves.toBe("abc"); }); - runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" }); + fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" }); }); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 405e46f..16b8b8e 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -78,6 +78,7 @@ export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; export async function enableFetchLogging() { const originalFetch = global.fetch; + // @ts-ignore global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const response = await originalFetch(input, init); const url = input instanceof URL || typeof input === "string" ? input : input.url; diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 3584317..f55591b 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import { mergeObject, randomString } from "../../src/core/utils"; import type { TAppMediaConfig } from "../../src/media/media-schema"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; beforeAll(() => { diff --git a/app/__test__/media/Storage.spec.ts b/app/__test__/media/Storage.spec.ts index f493606..1234123 100644 --- a/app/__test__/media/Storage.spec.ts +++ b/app/__test__/media/Storage.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage"; +import { type FileBody, Storage } from "../../src/media/storage/Storage"; import * as StorageEvents from "../../src/media/storage/events"; +import { StorageAdapter } from "media"; -class TestAdapter implements StorageAdapter { +class TestAdapter extends StorageAdapter { files: Record = {}; getName() { @@ -61,7 +62,7 @@ describe("Storage", async () => { test("uploads a file", async () => { const { meta: { type, size }, - } = await storage.uploadFile("hello", "world.txt"); + } = await storage.uploadFile("hello" as any, "world.txt"); expect({ type, size }).toEqual({ type: "text/plain", size: 0 }); }); @@ -71,6 +72,7 @@ describe("Storage", async () => { }); test("events were fired", async () => { + await storage.emgr.executeAsyncs(); expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue(); // @todo: file access must be tested in controllers diff --git a/app/__test__/media/StorageR2Adapter.native-spec.ts b/app/__test__/media/StorageR2Adapter.native-spec.ts deleted file mode 100644 index 64c7a9f..0000000 --- a/app/__test__/media/StorageR2Adapter.native-spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as assert from "node:assert/strict"; -import { createWriteStream } from "node:fs"; -import { test } from "node:test"; -import { Miniflare } from "miniflare"; - -// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 -console.log = async (message: any) => { - const tty = createWriteStream("/dev/tty"); - const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2); - return tty.write(`${msg}\n`); -}; - -test("what", async () => { - const mf = new Miniflare({ - modules: true, - script: "export default { async fetch() { return new Response(null); } }", - r2Buckets: ["BUCKET"], - }); - - const bucket = await mf.getR2Bucket("BUCKET"); - console.log(await bucket.put("count", "1")); - - const object = await bucket.get("count"); - if (object) { - /*const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set("etag", object.httpEtag);*/ - console.log("yo -->", await object.text()); - - assert.strictEqual(await object.text(), "1"); - } - - await mf.dispose(); -}); diff --git a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts b/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts deleted file mode 100644 index 9cac2e4..0000000 --- a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageCloudinaryAdapter } from "../../../src/media"; - -import { config } from "dotenv"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); -const { - CLOUDINARY_CLOUD_NAME, - CLOUDINARY_API_KEY, - CLOUDINARY_API_SECRET, - CLOUDINARY_UPLOAD_PRESET, -} = dotenvOutput.parsed!; - -const ALL_TESTS = !!process.env.ALL_TESTS; - -describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => { - if (ALL_TESTS) return; - - const adapter = new StorageCloudinaryAdapter({ - cloud_name: CLOUDINARY_CLOUD_NAME as string, - api_key: CLOUDINARY_API_KEY as string, - api_secret: CLOUDINARY_API_SECRET as string, - upload_preset: CLOUDINARY_UPLOAD_PRESET as string, - }); - - const file = Bun.file(`${import.meta.dir}/icon.png`); - const _filename = randomString(10); - const filename = `${_filename}.png`; - - test("object exists", async () => { - expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue(); - process.exit(); - }); - - test("puts object", async () => { - expect(await adapter.objectExists(filename)).toBeFalse(); - - const result = await adapter.putObject(filename, file); - console.log("result", result); - expect(result).toBeDefined(); - expect(result?.name).toBe(filename); - }); - - test("object exists", async () => { - await Bun.sleep(10000); - const one = await adapter.objectExists(_filename); - const two = await adapter.objectExists(filename); - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test("object meta", async () => { - const result = await adapter.getObjectMeta(filename); - console.log("objectMeta:result", result); - expect(result).toBeDefined(); - expect(result.type).toBe("image/png"); - expect(result.size).toBeGreaterThan(0); - }); - - test("list objects", async () => { - const result = await adapter.listObjects(); - console.log("listObjects:result", result); - }); -}); diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts deleted file mode 100644 index b23f84d..0000000 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter"; -import { assetsPath, assetsTmpPath } from "../../helper"; - -describe("StorageLocalAdapter", () => { - const adapter = new StorageLocalAdapter({ - path: assetsTmpPath, - }); - - const file = Bun.file(`${assetsPath}/image.png`); - const _filename = randomString(10); - const filename = `${_filename}.png`; - - let objects = 0; - - test("puts an object", async () => { - objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file as unknown as File)).toBeString(); - }); - - test("lists objects", async () => { - expect((await adapter.listObjects()).length).toBe(objects + 1); - }); - - test("file exists", async () => { - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test("gets an object", async () => { - const res = await adapter.getObject(filename, new Headers()); - expect(res.ok).toBeTrue(); - // @todo: check the content - }); - - test("gets object meta", async () => { - expect(await adapter.getObjectMeta(filename)).toEqual({ - type: file.type, // image/png - size: file.size, - }); - }); - - test("deletes an object", async () => { - expect(await adapter.deleteObject(filename)).toBeUndefined(); - expect(await adapter.objectExists(filename)).toBeFalse(); - }); -}); diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts deleted file mode 100644 index 7b4a0a4..0000000 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageS3Adapter } from "../../../src/media"; - -import { config } from "dotenv"; -//import { enableFetchLogging } from "../../helper"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); -const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = - dotenvOutput.parsed!; - -// @todo: mock r2/s3 responses for faster tests -const ALL_TESTS = !!process.env.ALL_TESTS; -console.log("ALL_TESTS?", ALL_TESTS); - -/* -// @todo: preparation to mock s3 calls + replace fast-xml-parser -let cleanup: () => void; -beforeAll(async () => { - cleanup = await enableFetchLogging(); -}); -afterAll(() => { - cleanup(); -}); */ - -describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { - if (ALL_TESTS) return; - - const versions = [ - [ - "r2", - new StorageS3Adapter({ - access_key: R2_ACCESS_KEY as string, - secret_access_key: R2_SECRET_ACCESS_KEY as string, - url: R2_URL as string, - }), - ], - [ - "s3", - new StorageS3Adapter({ - access_key: AWS_ACCESS_KEY as string, - secret_access_key: AWS_SECRET_KEY as string, - url: AWS_S3_URL as string, - }), - ], - ] as const; - - const _conf = { - adapters: ["r2", "s3"], - tests: [ - "listObjects", - "putObject", - "objectExists", - "getObject", - "deleteObject", - "getObjectMeta", - ], - }; - - const file = Bun.file(`${import.meta.dir}/icon.png`); - const filename = `${randomString(10)}.png`; - - // single (dev) - //_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] }; - - function disabled(test: (typeof _conf.tests)[number]) { - return !_conf.tests.includes(test); - } - - // @todo: add mocked fetch for faster tests - describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => { - if (!_conf.adapters.includes(name) || ALL_TESTS) { - console.log("Skipping", name); - return; - } - - let objects = 0; - - test.skipIf(disabled("putObject"))("puts an object", async () => { - objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file as any)).toBeString(); - }); - - test.skipIf(disabled("listObjects"))("lists objects", async () => { - expect((await adapter.listObjects()).length).toBe(objects + 1); - }); - - test.skipIf(disabled("objectExists"))("file exists", async () => { - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test.skipIf(disabled("getObject"))("gets an object", async () => { - const res = await adapter.getObject(filename, new Headers()); - expect(res.ok).toBeTrue(); - // @todo: check the content - }); - - test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => { - expect(await adapter.getObjectMeta(filename)).toEqual({ - type: file.type, // image/png - size: file.size, - }); - }); - - test.skipIf(disabled("deleteObject"))("deletes an object", async () => { - expect(await adapter.deleteObject(filename)).toBeUndefined(); - expect(await adapter.objectExists(filename)).toBeFalse(); - }); - }); -}); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 6c51fab..dd13f7c 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils"; describe("media/mime-types", () => { test("tiny resolves", () => { - const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + const tests = [ + [".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"], + ] as const; for (const [ext, mime] of tests) { expect(tiny.guess(ext)).toBe(mime); @@ -69,7 +71,7 @@ describe("media/mime-types", () => { ["application/zip", "zip"], ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], - ]; + ] as const; for (const [mime, ext] of tests) { expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); @@ -86,7 +88,7 @@ describe("media/mime-types", () => { ["image.jpeg", "jpeg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], - ]; + ] as const; for (const [filename, ext] of tests) { expect( @@ -94,5 +96,10 @@ describe("media/mime-types", () => { `getRandomizedFilename(): ${filename} should end with ${ext}`, ).toBe(ext); } + + // make sure it keeps the extension, even if the file has a different type + const file = new File([""], "image.jpg", { type: "text/plain" }); + const [, ext] = getRandomizedFilename(file).split("."); + expect(ext).toBe("jpg"); }); }); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index 1423fd6..8e6b5b2 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import { em, entity, text } from "../../src/data"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { AppMedia } from "../../src/modules"; import { moduleTestSuite } from "./module-test-suite"; 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/build.ts b/app/build.ts index 1720b2c..56dc1dc 100644 --- a/app/build.ts +++ b/app/build.ts @@ -54,7 +54,7 @@ function banner(title: string) { } // collection of always-external packages -const external = ["bun:test", "@libsql/client"] as const; +const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const; /** * Building backend and general API @@ -65,7 +65,13 @@ async function buildApi() { minify, sourcemap, watch, - entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], + entry: [ + "src/index.ts", + "src/core/index.ts", + "src/core/utils/index.ts", + "src/data/index.ts", + "src/media/index.ts", + ], outDir: "dist", external: [...external], metafile: true, diff --git a/app/e2e/adapters.ts b/app/e2e/adapters.ts new file mode 100644 index 0000000..cac87e1 --- /dev/null +++ b/app/e2e/adapters.ts @@ -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); +} 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..20bbd70 --- /dev/null +++ b/app/e2e/base.e2e-spec.ts @@ -0,0 +1,25 @@ +// @ts-check +import { test, expect } from "@playwright/test"; +import { testIds } from "../src/ui/lib/config"; + +import { getAdapterConfig } from "./inc/adapters"; +const config = getAdapterConfig(); + +test("start page has expected title", async ({ page }) => { + await page.goto(config.base_path); + await expect(page).toHaveTitle(/BKND/); +}); + +test("start page has expected heading", async ({ page }) => { + await page.goto(config.base_path); + + // 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(config.base_path); + 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..347d23b --- /dev/null +++ b/app/e2e/inc/adapters.ts @@ -0,0 +1,44 @@ +const adapter = process.env.TEST_ADAPTER; + +const default_config = { + media_adapter: "local", + base_path: "", +} as const; + +const configs = { + cloudflare: { + 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 { + if (adapter) { + if (!configs[adapter]) { + console.warn( + `Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`, + ); + } else { + return { + ...default_config, + ...configs[adapter], + }; + } + } + + return default_config; +} diff --git a/app/e2e/media.e2e-spec.ts b/app/e2e/media.e2e-spec.ts new file mode 100644 index 0000000..307ba39 --- /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 config = getAdapterConfig(); + +test("can enable media", async ({ page }) => { + await page.goto(`${config.base_path}/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-${config.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(config.media_adapter); + } +}); + +test("can upload a file", async ({ page }) => { + await page.goto(`${config.base_path}/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 361acc9..accd53c 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.10.2", + "version": "0.11.0", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -15,10 +15,9 @@ }, "scripts": { "dev": "vite", - "test": "ALL_TESTS=1 bun test --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", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify", "build:static": "vite build", "watch": "bun run build.ts --types --watch", @@ -27,8 +26,22 @@ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "updater": "bun x npm-check-updates -ui", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./", - "postpublish": "rm -f README.md" + "prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../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:adapters": "bun run e2e/adapters.ts", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:report": "playwright show-report" }, "license": "FSL-1.1-MIT", "dependencies": { @@ -37,7 +50,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-liquid": "^6.2.2", "@hello-pangea/dnd": "^18.0.1", - "@libsql/client": "^0.14.0", + "@libsql/client": "^0.15.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", "@sinclair/typebox": "^0.34.30", @@ -58,7 +71,8 @@ "object-path-immutable": "^4.1.2", "picocolors": "^1.1.1", "radix-ui": "^1.1.3", - "swr": "^2.3.3" + "swr": "^2.3.3", + "wrangler": "^4.4.1" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -70,18 +84,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", @@ -100,8 +119,10 @@ "tailwindcss-animate": "^1.0.7", "tsc-alias": "^1.8.11", "tsup": "^8.4.0", + "tsx": "^4.19.3", "vite": "^6.2.1", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.9", "wouter": "^3.6.0" }, "optionalDependencies": { @@ -118,47 +139,52 @@ ".": { "types": "./dist/types/index.d.ts", "import": "./dist/index.js", - "require": "./dist/index.cjs" + "require": "./dist/index.js" }, "./ui": { "types": "./dist/types/ui/index.d.ts", "import": "./dist/ui/index.js", - "require": "./dist/ui/index.cjs" + "require": "./dist/ui/index.js" }, "./elements": { "types": "./dist/types/ui/elements/index.d.ts", "import": "./dist/ui/elements/index.js", - "require": "./dist/ui/elements/index.cjs" + "require": "./dist/ui/elements/index.js" }, "./client": { "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", - "require": "./dist/ui/client/index.cjs" + "require": "./dist/ui/client/index.js" }, "./data": { "types": "./dist/types/data/index.d.ts", "import": "./dist/data/index.js", - "require": "./dist/data/index.cjs" + "require": "./dist/data/index.js" }, "./core": { "types": "./dist/types/core/index.d.ts", "import": "./dist/core/index.js", - "require": "./dist/core/index.cjs" + "require": "./dist/core/index.js" }, "./utils": { "types": "./dist/types/core/utils/index.d.ts", "import": "./dist/core/utils/index.js", - "require": "./dist/core/utils/index.cjs" + "require": "./dist/core/utils/index.js" }, "./cli": { "types": "./dist/types/cli/index.d.ts", "import": "./dist/cli/index.js", - "require": "./dist/cli/index.cjs" + "require": "./dist/cli/index.js" + }, + "./media": { + "types": "./dist/types/media/index.d.ts", + "import": "./dist/media/index.js", + "require": "./dist/media/index.js" }, "./adapter/cloudflare": { "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", - "require": "./dist/adapter/cloudflare/index.cjs" + "require": "./dist/adapter/cloudflare/index.js" }, "./adapter": { "types": "./dist/types/adapter/index.d.ts", @@ -167,37 +193,37 @@ "./adapter/vite": { "types": "./dist/types/adapter/vite/index.d.ts", "import": "./dist/adapter/vite/index.js", - "require": "./dist/adapter/vite/index.cjs" + "require": "./dist/adapter/vite/index.js" }, "./adapter/nextjs": { "types": "./dist/types/adapter/nextjs/index.d.ts", "import": "./dist/adapter/nextjs/index.js", - "require": "./dist/adapter/nextjs/index.cjs" + "require": "./dist/adapter/nextjs/index.js" }, "./adapter/react-router": { "types": "./dist/types/adapter/react-router/index.d.ts", "import": "./dist/adapter/react-router/index.js", - "require": "./dist/adapter/react-router/index.cjs" + "require": "./dist/adapter/react-router/index.js" }, "./adapter/bun": { "types": "./dist/types/adapter/bun/index.d.ts", "import": "./dist/adapter/bun/index.js", - "require": "./dist/adapter/bun/index.cjs" + "require": "./dist/adapter/bun/index.js" }, "./adapter/node": { "types": "./dist/types/adapter/node/index.d.ts", "import": "./dist/adapter/node/index.js", - "require": "./dist/adapter/node/index.cjs" + "require": "./dist/adapter/node/index.js" }, "./adapter/astro": { "types": "./dist/types/adapter/astro/index.d.ts", "import": "./dist/adapter/astro/index.js", - "require": "./dist/adapter/astro/index.cjs" + "require": "./dist/adapter/astro/index.js" }, "./adapter/aws": { "types": "./dist/types/adapter/aws/index.d.ts", "import": "./dist/adapter/aws/index.js", - "require": "./dist/adapter/aws/index.cjs" + "require": "./dist/adapter/aws/index.js" }, "./dist/main.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/styles.css", 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/Api.ts b/app/src/Api.ts index 70cbd13..593979e 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -78,6 +78,10 @@ export class Api { this.buildApis(); } + get fetcher() { + return this.options.fetcher ?? fetch; + } + get baseUrl() { return this.options.host ?? "http://localhost"; } diff --git a/app/src/App.ts b/app/src/App.ts index ac0ea1d..1e7b52a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -4,9 +4,10 @@ import { Event } from "core/events"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import type { Hono } from "hono"; import { + ModuleManager, type InitialModuleConfigs, type ModuleBuildContext, - ModuleManager, + type ModuleConfigs, type ModuleManagerOptions, type Modules, } from "modules/ModuleManager"; @@ -16,6 +17,7 @@ import { SystemController } from "modules/server/SystemController"; // biome-ignore format: must be there import { Api, type ApiOptions } from "Api"; +import type { ServerEnv } from "modules/Controller"; export type AppPlugin = (app: App) => Promise | void; @@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent { export class AppFirstBoot extends AppEvent { static override slug = "app-first-boot"; } -export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; +export class AppRequest extends AppEvent<{ request: Request }> { + static override slug = "app-request"; +} +export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> { + static override slug = "app-before-response"; +} +export const AppEvents = { + AppConfigUpdatedEvent, + AppBuiltEvent, + AppFirstBoot, + AppRequest, + AppBeforeResponse, +} as const; export type AppOptions = { plugins?: AppPlugin[]; seed?: (ctx: ModuleBuildContext & { app: App }) => Promise; manager?: Omit; + asyncEventsMode?: "sync" | "async" | "none"; }; export type CreateAppConfig = { connection?: @@ -53,12 +68,14 @@ export type AppConfig = InitialModuleConfigs; export type LocalApiOptions = Request | ApiOptions; export class App { - modules: ModuleManager; static readonly Events = AppEvents; + + modules: ModuleManager; adminController?: AdminController; + _id: string = crypto.randomUUID(); + private trigger_first_boot = false; private plugins: AppPlugin[]; - private _id: string = crypto.randomUUID(); private _building: boolean = false; constructor( @@ -70,35 +87,9 @@ export class App { this.modules = new ModuleManager(connection, { ...(options?.manager ?? {}), initial: _initialConfig, - onUpdated: async (key, config) => { - // if the EventManager was disabled, we assume we shouldn't - // respond to events, such as "onUpdated". - // this is important if multiple changes are done, and then build() is called manually - if (!this.emgr.enabled) { - $console.warn("App config updated, but event manager is disabled, skip."); - return; - } - - $console.log("App config updated", key); - // @todo: potentially double syncing - await this.build({ sync: true }); - await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); - }, - onFirstBoot: async () => { - $console.log("App first boot"); - this.trigger_first_boot = true; - }, - onServerInit: async (server) => { - server.use(async (c, next) => { - c.set("app", this); - await next(); - - try { - // gracefully add the app id - c.res.headers.set("X-bknd-id", this._id); - } catch (e) {} - }); - }, + onUpdated: this.onUpdated.bind(this), + onFirstBoot: this.onFirstBoot.bind(this), + onServerInit: this.onServerInit.bind(this), }); this.modules.ctx().emgr.registerEvents(AppEvents); } @@ -189,7 +180,10 @@ export class App { registerAdminController(config?: AdminControllerOptions) { // register admin this.adminController = new AdminController(this, config); - this.modules.server.route(config?.basepath ?? "/", this.adminController.getController()); + this.modules.server.route( + this.adminController.basepath, + this.adminController.getController(), + ); return this; } @@ -213,6 +207,53 @@ export class App { return new Api({ host: "http://localhost", ...(options ?? {}), fetcher }); } + + async onUpdated(module: Module, config: ModuleConfigs[Module]) { + // if the EventManager was disabled, we assume we shouldn't + // respond to events, such as "onUpdated". + // this is important if multiple changes are done, and then build() is called manually + if (!this.emgr.enabled) { + $console.warn("App config updated, but event manager is disabled, skip."); + return; + } + + $console.log("App config updated", module); + // @todo: potentially double syncing + await this.build({ sync: true }); + await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); + } + + async onFirstBoot() { + $console.log("App first boot"); + this.trigger_first_boot = true; + } + + async onServerInit(server: Hono) { + server.use(async (c, next) => { + c.set("app", this); + await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw })); + await next(); + + try { + // gracefully add the app id + c.res.headers.set("X-bknd-id", this._id); + } catch (e) {} + + await this.emgr.emit( + new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }), + ); + + // execute collected async events (async by default) + switch (this.options?.asyncEventsMode ?? "async") { + case "sync": + await this.emgr.executeAsyncs(); + break; + case "async": + this.emgr.executeAsyncs(); + break; + } + }); + } } export function createApp(config: CreateAppConfig = {}) { diff --git a/app/src/adapter/adapter-test-suite.ts b/app/src/adapter/adapter-test-suite.ts new file mode 100644 index 0000000..0ddb2b7 --- /dev/null +++ b/app/src/adapter/adapter-test-suite.ts @@ -0,0 +1,90 @@ +import type { TestRunner } from "core/test"; +import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index"; +import type { App } from "App"; + +export function adapterTestSuite< + Config extends BkndConfig = BkndConfig, + Args extends DefaultArgs = DefaultArgs, +>( + testRunner: TestRunner, + { + makeApp, + makeHandler, + label = "app", + overrides = {}, + }: { + makeApp: ( + config: Config, + args?: Args, + opts?: RuntimeOptions | FrameworkOptions, + ) => Promise; + makeHandler?: ( + config?: Config, + args?: Args, + opts?: RuntimeOptions | FrameworkOptions, + ) => (request: Request) => Promise; + label?: string; + overrides?: { + dbUrl?: string; + }; + }, +) { + const { test, expect, mock } = testRunner; + const id = crypto.randomUUID(); + + test(`creates ${label}`, async () => { + const beforeBuild = mock(async () => null) as any; + const onBuilt = mock(async () => null) as any; + + const config = { + app: (env) => ({ + connection: { url: env.url }, + initialConfig: { + server: { cors: { origin: env.origin } }, + }, + }), + beforeBuild, + onBuilt, + } as const satisfies BkndConfig; + + const app = await makeApp( + config as any, + { + url: overrides.dbUrl ?? ":memory:", + origin: "localhost", + } as any, + { id }, + ); + expect(app).toBeDefined(); + expect(app.toJSON().server.cors.origin).toEqual("localhost"); + expect(beforeBuild).toHaveBeenCalledTimes(1); + expect(onBuilt).toHaveBeenCalledTimes(1); + }); + + if (makeHandler) { + const getConfig = async (fetcher: (r: Request) => Promise) => { + const res = await fetcher(new Request("http://localhost:3000/api/system/config")); + const data = (await res.json()) as any; + return { res, data }; + }; + + test("responds with the same app id", async () => { + const fetcher = makeHandler(undefined, undefined, { id }); + + const { res, data } = await getConfig(fetcher); + expect(res.ok).toBe(true); + expect(res.status).toBe(200); + expect(data.server.cors.origin).toEqual("localhost"); + }); + + test("creates fresh & responds to api config", async () => { + // set the same id, but force recreate + const fetcher = makeHandler(undefined, undefined, { id, force: true }); + + const { res, data } = await getConfig(fetcher); + expect(res.ok).toBe(true); + expect(res.status).toBe(200); + expect(data.server.cors.origin).toEqual("*"); + }); + } +} diff --git a/app/src/adapter/astro/astro.adapter.spec.ts b/app/src/adapter/astro/astro.adapter.spec.ts new file mode 100644 index 0000000..3f3d1e8 --- /dev/null +++ b/app/src/adapter/astro/astro.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as astro from "./astro.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("astro adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: astro.getApp, + makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }), + }); +}); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 61971e3..a684f73 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,34 +1,25 @@ -import type { App } from "bknd"; -import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { Api, type ApiOptions } from "bknd/client"; - -export type AstroBkndConfig = FrameworkBkndConfig; +import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; +type AstroEnv = NodeJS.ProcessEnv; type TAstro = { request: Request; }; +export type AstroBkndConfig = FrameworkBkndConfig; -export type Options = { - mode?: "static" | "dynamic"; -} & Omit & { - host?: string; - }; - -export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) { - const api = new Api({ - host: new URL(Astro.request.url).origin, - headers: options.mode === "dynamic" ? Astro.request.headers : undefined, - }); - await api.verifyAuth(); - return api; +export async function getApp( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts: FrameworkOptions = {}, +) { + return await createFrameworkApp(config, args ?? import.meta.env, opts); } -let app: App; -export function serve(config: AstroBkndConfig = {}) { - return async (args: Context) => { - if (!app) { - app = await createFrameworkApp(config, args); - } - return app.fetch(args.request); +export function serve( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, +) { + return async (fnArgs: TAstro) => { + return (await getApp(config, args, opts)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/aws/aws-lambda.adapter.ts b/app/src/adapter/aws/aws-lambda.adapter.ts index 9488065..ad19047 100644 --- a/app/src/adapter/aws/aws-lambda.adapter.ts +++ b/app/src/adapter/aws/aws-lambda.adapter.ts @@ -1,68 +1,76 @@ import type { App } from "bknd"; import { handle } from "hono/aws-lambda"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; -export type AwsLambdaBkndConfig = RuntimeBkndConfig & { - assets?: - | { - mode: "local"; - root: string; - } - | { - mode: "url"; - url: string; - }; -}; +type AwsLambdaEnv = object; +export type AwsLambdaBkndConfig = + RuntimeBkndConfig & { + assets?: + | { + mode: "local"; + root: string; + } + | { + mode: "url"; + url: string; + }; + }; -let app: App; -export async function createApp({ - adminOptions = false, - assets, - ...config -}: AwsLambdaBkndConfig = {}) { - if (!app) { - let additional: Partial = { - adminOptions, - }; +export async function createApp( + { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +): Promise { + let additional: Partial = { + adminOptions, + }; - if (assets?.mode) { - switch (assets.mode) { - case "local": - // @todo: serve static outside app context - additional = { - adminOptions: adminOptions === false ? undefined : adminOptions, - serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({ - root: assets.root, - onFound: (path, c) => { - c.res.headers.set("Cache-Control", "public, max-age=31536000"); - }, - }), - }; - break; - case "url": - additional.adminOptions = { - ...(typeof adminOptions === "object" ? adminOptions : {}), - assets_path: assets.url, - }; - break; - default: - throw new Error("Invalid assets mode"); - } + if (assets?.mode) { + switch (assets.mode) { + case "local": + // @todo: serve static outside app context + additional = { + adminOptions: adminOptions === false ? undefined : adminOptions, + serveStatic: serveStatic({ + root: assets.root, + onFound: (path, c) => { + c.res.headers.set("Cache-Control", "public, max-age=31536000"); + }, + }), + }; + break; + case "url": + additional.adminOptions = { + ...(typeof adminOptions === "object" ? adminOptions : {}), + assetsPath: assets.url, + }; + break; + default: + throw new Error("Invalid assets mode"); } - - app = await createRuntimeApp({ - ...config, - ...additional, - }); } - return app; + return await createRuntimeApp( + { + ...config, + ...additional, + }, + args ?? process.env, + opts, + ); } -export function serveLambda(config: AwsLambdaBkndConfig = {}) { - console.log("serving lambda"); +export function serve( + config: AwsLambdaBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { return async (event) => { - const app = await createApp(config); + const app = await createApp(config, args, opts); return await handle(app.server)(event); }; } + +// compatibility with old code +export const serveLambda = serve; diff --git a/app/src/adapter/aws/aws.adapter.spec.ts b/app/src/adapter/aws/aws.adapter.spec.ts new file mode 100644 index 0000000..e6873d8 --- /dev/null +++ b/app/src/adapter/aws/aws.adapter.spec.ts @@ -0,0 +1,19 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as awsLambda from "./aws-lambda.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("aws adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: awsLambda.createApp, + // @todo: add a request to lambda event translator? + makeHandler: (c, a, o) => async (request: Request) => { + const app = await awsLambda.createApp(c, a, o); + return app.fetch(request); + }, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.spec.ts b/app/src/adapter/bun/bun.adapter.spec.ts new file mode 100644 index 0000000..7423190 --- /dev/null +++ b/app/src/adapter/bun/bun.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as bun from "./bun.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("bun adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: bun.createApp, + makeHandler: bun.createHandler, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 2087c5e..44fb795 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,47 +1,64 @@ /// import path from "node:path"; -import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { config } from "bknd/core"; import type { ServeOptions } from "bun"; import { serveStatic } from "hono/bun"; -let app: App; +type BunEnv = Bun.Env; +export type BunBkndConfig = RuntimeBkndConfig & Omit; -export type BunBkndConfig = RuntimeBkndConfig & Omit; - -export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { +export async function createApp( + { distPath, ...config }: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); + registerLocalMediaAdapter(); - if (!app) { - registerLocalMediaAdapter(); - app = await createRuntimeApp({ + return await createRuntimeApp( + { ...config, serveStatic: serveStatic({ root }), - }); - } - - return app; + }, + args ?? (process.env as Env), + opts, + ); } -export function serve({ - distPath, - connection, - initialConfig, - options, - port = config.server.default_port, - onBuilt, - buildConfig, - adminOptions, - ...serveOptions -}: BunBkndConfig = {}) { +export function createHandler( + config: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return async (req: Request) => { + const app = await createApp(config, args ?? (process.env as Env), opts); + return app.fetch(req); + }; +} + +export function serve( + { + distPath, + connection, + initialConfig, + options, + port = config.server.default_port, + onBuilt, + buildConfig, + adminOptions, + ...serveOptions + }: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { Bun.serve({ ...serveOptions, port, - fetch: async (request: Request) => { - const app = await createApp({ + fetch: createHandler( + { connection, initialConfig, options, @@ -49,9 +66,10 @@ export function serve({ buildConfig, adminOptions, distPath, - }); - return app.fetch(request); - }, + }, + args, + opts, + ), }); console.log(`Server is running on http://localhost:${port}`); diff --git a/app/src/adapter/bun/test.ts b/app/src/adapter/bun/test.ts new file mode 100644 index 0000000..7bd314a --- /dev/null +++ b/app/src/adapter/bun/test.ts @@ -0,0 +1,7 @@ +import { expect, test, mock } from "bun:test"; + +export const bunTestRunner = { + expect, + test, + mock, +}; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts new file mode 100644 index 0000000..22449a4 --- /dev/null +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { makeApp } from "./modes/fresh"; +import { makeConfig } from "./config"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("cf adapter", () => { + const DB_URL = ":memory:"; + const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({ + request: request ?? (null as any), + env: env ?? { DB_URL }, + ctx: ctx ?? (null as any), + }); + + it("makes config", async () => { + expect( + makeConfig( + { + connection: { url: DB_URL }, + }, + {}, + ), + ).toEqual({ connection: { url: DB_URL } }); + + expect( + makeConfig( + { + app: (env) => ({ + connection: { url: env.DB_URL }, + }), + }, + { + DB_URL, + }, + ), + ).toEqual({ connection: { url: DB_URL } }); + }); + + adapterTestSuite(bunTestRunner, { + makeApp, + makeHandler: (c, a, o) => { + return async (request: any) => { + const app = await makeApp( + // needs a fallback, otherwise tries to launch D1 + c ?? { + connection: { url: DB_URL }, + }, + a, + o, + ); + return app.fetch(request); + }; + }, + }); +}); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 7483d52..523372f 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,18 +1,17 @@ /// -import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter"; +import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import { D1Connection } from "./D1Connection"; -import { registerMedia } from "./StorageR2Adapter"; -import { getBinding } from "./bindings"; +import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; -import { getFresh, getWarm } from "./modes/fresh"; +import type { App } from "bknd"; -export type CloudflareBkndConfig = FrameworkBkndConfig> & { +export type CloudflareEnv = object; +export type CloudflareBkndConfig = RuntimeBkndConfig & { mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Context) => { + bindings?: (args: Env) => { kv?: KVNamespace; dobj?: DurableObjectNamespace; db?: D1Database; @@ -22,49 +21,17 @@ export type CloudflareBkndConfig = FrameworkBkndConfig> keepAliveSeconds?: number; forceHttps?: boolean; manifest?: string; - setAdminHtml?: boolean; - html?: string; }; -export type Context = { +export type Context = { request: Request; env: Env; ctx: ExecutionContext; }; -let media_registered: boolean = false; -export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { - if (!media_registered) { - registerMedia(context.env as any); - media_registered = true; - } - - const appConfig = makeConfig(config, context); - const bindings = config.bindings?.(context); - if (!appConfig.connection) { - let db: D1Database | undefined; - if (bindings?.db) { - console.log("Using database from bindings"); - db = bindings.db; - } else if (Object.keys(context.env ?? {}).length > 0) { - const binding = getBinding(context.env, "D1Database"); - if (binding) { - console.log(`Using database from env "${binding.key}"`); - db = binding.value; - } - } - - if (db) { - appConfig.connection = new D1Connection({ binding: db }); - } else { - throw new Error("No database connection given"); - } - } - - return appConfig; -} - -export function serve(config: CloudflareBkndConfig = {}) { +export function serve( + config: CloudflareBkndConfig = {}, +) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); @@ -75,7 +42,7 @@ export function serve(config: CloudflareBkndConfig = {}) { throw new Error("manifest is required with static 'kv'"); } - if (config.manifest && config.static !== "assets") { + if (config.manifest && config.static === "kv") { const pathname = url.pathname.slice(1); const assetManifest = JSON.parse(config.manifest); if (pathname && pathname in assetManifest) { @@ -99,21 +66,27 @@ export function serve(config: CloudflareBkndConfig = {}) { } } - const context = { request, env, ctx } as Context; + const context = { request, env, ctx } as Context; const mode = config.mode ?? "warm"; + let app: App; switch (mode) { case "fresh": - return await getFresh(config, context); + app = await getFresh(config, context, { force: true }); + break; case "warm": - return await getWarm(config, context); + app = await getFresh(config, context); + break; case "cache": - return await getCached(config, context); + app = await getCached(config, context); + break; case "durable": return await getDurable(config, context); default: throw new Error(`Unknown mode ${mode}`); } + + return app.fetch(request, env, ctx); }, }; } diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts new file mode 100644 index 0000000..4b9f3d7 --- /dev/null +++ b/app/src/adapter/cloudflare/config.ts @@ -0,0 +1,64 @@ +import { registerMedia } from "./storage/StorageR2Adapter"; +import { getBinding } from "./bindings"; +import { D1Connection } from "./D1Connection"; +import type { CloudflareBkndConfig, CloudflareEnv } from "."; +import { App } from "bknd"; +import { makeConfig as makeAdapterConfig } from "bknd/adapter"; +import type { ExecutionContext } from "hono"; + +export const constants = { + exec_async_event_id: "cf_register_waituntil", + cache_endpoint: "/__bknd/cache", + do_endpoint: "/__bknd/do", +}; + +let media_registered: boolean = false; +export function makeConfig( + config: CloudflareBkndConfig, + args: Env = {} as Env, +) { + if (!media_registered) { + registerMedia(args as any); + media_registered = true; + } + + const appConfig = makeAdapterConfig(config, args); + const bindings = config.bindings?.(args); + if (!appConfig.connection) { + let db: D1Database | undefined; + if (bindings?.db) { + console.log("Using database from bindings"); + db = bindings.db; + } else if (Object.keys(args).length > 0) { + const binding = getBinding(args, "D1Database"); + if (binding) { + console.log(`Using database from env "${binding.key}"`); + db = binding.value; + } + } + + if (db) { + appConfig.connection = new D1Connection({ binding: db }); + } else { + throw new Error("No database connection given"); + } + } + + return appConfig; +} + +export function registerAsyncsExecutionContext( + app: App, + ctx: { waitUntil: ExecutionContext["waitUntil"] }, +) { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); +} diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index e89198e..f53f908 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,7 +1,7 @@ import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; export * from "./cloudflare-workers.adapter"; -export { makeApp, getFresh, getWarm } from "./modes/fresh"; +export { makeApp, getFresh } from "./modes/fresh"; export { getCached } from "./modes/cached"; export { DurableBkndApp, getDurable } from "./modes/durable"; export { D1Connection, type D1ConnectionConfig }; diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index c126ff7..3685f7b 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,8 +1,12 @@ import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { makeConfig, registerAsyncsExecutionContext, constants } from "../config"; -export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { +export async function getCached( + config: CloudflareBkndConfig, + { env, ctx, ...args }: Context, +) { const { kv } = config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -16,10 +20,11 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg const app = await createRuntimeApp( { - ...makeCfConfig(config, { env, ctx, ...args }), + ...makeConfig(config, env), initialConfig, onBuilt: async (app) => { - app.module.server.client.get("/__bknd/cache", async (c) => { + registerAsyncsExecutionContext(app, ctx); + app.module.server.client.get(constants.cache_endpoint, async (c) => { await kv.delete(key); return c.json({ message: "Cache cleared" }); }); @@ -35,7 +40,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg ); await config.beforeBuild?.(app); }, - adminOptions: { html: config.html }, }, { env, ctx, ...args }, ); diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 63fce34..414c197 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -1,9 +1,13 @@ import { DurableObject } from "cloudflare:workers"; import type { App, CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context } from "../index"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { constants, registerAsyncsExecutionContext } from "../config"; -export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { +export async function getDurable( + config: CloudflareBkndConfig, + ctx: Context, +) { const { dobj } = config.bindings?.(ctx.env)!; if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -17,13 +21,11 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const id = dobj.idFromName(key); const stub = dobj.get(id) as unknown as DurableBkndApp; - const create_config = makeConfig(config, ctx); + const create_config = makeConfig(config, ctx.env); const res = await stub.fire(ctx.request, { config: create_config, - html: config.html, keepAliveSeconds: config.keepAliveSeconds, - setAdminHtml: config.setAdminHtml, }); const headers = new Headers(res.headers); @@ -67,7 +69,8 @@ export class DurableBkndApp extends DurableObject { this.app = await createRuntimeApp({ ...config, onBuilt: async (app) => { - app.modules.server.get("/__do", async (c) => { + registerAsyncsExecutionContext(app, this.ctx); + app.modules.server.get(constants.do_endpoint, async (c) => { // @ts-ignore const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; return c.json({ @@ -92,7 +95,6 @@ export class DurableBkndApp extends DurableObject { this.keepAlive(options.keepAliveSeconds); } - console.log("id", this.id); const res = await this.app!.fetch(request); const headers = new Headers(res.headers); headers.set("X-BuildTime", buildtime.toString()); @@ -106,19 +108,17 @@ export class DurableBkndApp extends DurableObject { } async onBuilt(app: App) {} + async beforeBuild(app: App) {} protected keepAlive(seconds: number) { - console.log("keep alive for", seconds); if (this.interval) { - console.log("clearing, there is a new"); clearInterval(this.interval); } let i = 0; this.interval = setInterval(() => { i += 1; - //console.log("keep-alive", i); if (i === seconds) { console.log("cleared"); clearInterval(this.interval); diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index b13c537..d894065 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,27 +1,29 @@ -import type { App } from "bknd"; -import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { makeConfig, registerAsyncsExecutionContext } from "../config"; -export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { - return await createRuntimeApp( +export async function makeApp( + config: CloudflareBkndConfig, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return await createRuntimeApp(makeConfig(config, args), args, opts); +} + +export async function getFresh( + config: CloudflareBkndConfig, + ctx: Context, + opts: RuntimeOptions = {}, +) { + return await makeApp( { - ...makeCfConfig(config, ctx), - adminOptions: config.html ? { html: config.html } : undefined, + ...config, + onBuilt: async (app) => { + registerAsyncsExecutionContext(app, ctx.ctx); + config.onBuilt?.(app); + }, }, - ctx, + ctx.env, + opts, ); } - -export async function getFresh(config: CloudflareBkndConfig, ctx: Context) { - const app = await makeApp(config, ctx); - return app.fetch(ctx.request); -} - -let warm_app: App; -export async function getWarm(config: CloudflareBkndConfig, ctx: Context) { - if (!warm_app) { - warm_app = await makeApp(config, ctx); - } - - return warm_app.fetch(ctx.request); -} diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts new file mode 100644 index 0000000..16c4fa5 --- /dev/null +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts @@ -0,0 +1,32 @@ +import { createWriteStream, readFileSync } from "node:fs"; +import { test } from "node:test"; +import { Miniflare } from "miniflare"; +import { StorageR2Adapter } from "./StorageR2Adapter"; +import { adapterTestSuite } from "media"; +import { nodeTestRunner } from "adapter/node"; +import path from "node:path"; + +// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 +console.log = async (message: any) => { + const tty = createWriteStream("/dev/tty"); + const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2); + return tty.write(`${msg}\n`); +}; + +test("StorageR2Adapter", async () => { + const mf = new Miniflare({ + modules: true, + script: "export default { async fetch() { return new Response(null); } }", + r2Buckets: ["BUCKET"], + }); + + const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket; + const adapter = new StorageR2Adapter(bucket); + + const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); + const buffer = readFileSync(path.join(basePath, "image.png")); + const file = new File([buffer], "image.png", { type: "image/png" }); + + await adapterTestSuite(nodeTestRunner, adapter, file); + await mf.dispose(); +}); diff --git a/app/src/adapter/cloudflare/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts similarity index 94% rename from app/src/adapter/cloudflare/StorageR2Adapter.ts rename to app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 5432e79..62c41f6 100644 --- a/app/src/adapter/cloudflare/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,9 +1,8 @@ import { registries } from "bknd"; import { isDebug } from "bknd/core"; import { StringEnum, Type } from "bknd/utils"; -import type { FileBody, StorageAdapter } from "media/storage/Storage"; -import { guess } from "media/storage/mime-types-tiny"; -import { getBindings } from "./bindings"; +import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media"; +import { getBindings } from "../bindings"; export function makeSchema(bindings: string[] = []) { return Type.Object( @@ -47,8 +46,10 @@ export function registerMedia(env: Record) { * Adapter for R2 storage * @todo: add tests (bun tests won't work, need node native tests) */ -export class StorageR2Adapter implements StorageAdapter { - constructor(private readonly bucket: R2Bucket) {} +export class StorageR2Adapter extends StorageAdapter { + constructor(private readonly bucket: R2Bucket) { + super(); + } getName(): string { return "r2"; diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 6d416e9..84fb8c5 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -12,76 +12,113 @@ export type BkndConfig = CreateAppConfig & { export type FrameworkBkndConfig = BkndConfig; +export type CreateAdapterAppOptions = { + force?: boolean; + id?: string; +}; +export type FrameworkOptions = CreateAdapterAppOptions; +export type RuntimeOptions = CreateAdapterAppOptions; + export type RuntimeBkndConfig = BkndConfig & { distPath?: string; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; adminOptions?: AdminControllerOptions | false; }; -export function makeConfig(config: BkndConfig, args?: Args): CreateAppConfig { +export type DefaultArgs = { + [key: string]: any; +}; + +export function makeConfig( + config: BkndConfig, + args?: Args, +): CreateAppConfig { let additionalConfig: CreateAppConfig = {}; - if ("app" in config && config.app) { - if (typeof config.app === "function") { + const { app, ...rest } = config; + if (app) { + if (typeof app === "function") { if (!args) { throw new Error("args is required when config.app is a function"); } - additionalConfig = config.app(args); + additionalConfig = app(args); } else { - additionalConfig = config.app; + additionalConfig = app; } } - return { ...config, ...additionalConfig }; + return { ...rest, ...additionalConfig }; } -export async function createFrameworkApp( - config: FrameworkBkndConfig, +// a map that contains all apps by id +const apps = new Map(); +export async function createAdapterApp( + config: Config = {} as Config, args?: Args, + opts?: CreateAdapterAppOptions, ): Promise { - const app = App.create(makeConfig(config, args)); + const id = opts?.id ?? "app"; + let app = apps.get(id); + if (!app || opts?.force) { + app = App.create(makeConfig(config, args)); + apps.set(id, app); + } + return app; +} - if (config.onBuilt) { +export async function createFrameworkApp( + config: FrameworkBkndConfig = {}, + args?: Args, + opts?: FrameworkOptions, +): Promise { + const app = await createAdapterApp(config, args, opts); + + if (!app.isBuilt()) { + if (config.onBuilt) { + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async () => { + await config.onBuilt?.(app); + }, + "sync", + ); + } + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); + } + + return app; +} + +export async function createRuntimeApp( + { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, + args?: Args, + opts?: RuntimeOptions, +): Promise { + const app = await createAdapterApp(config, args, opts); + + if (!app.isBuilt()) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { + if (serveStatic) { + const [path, handler] = Array.isArray(serveStatic) + ? serveStatic + : [$config.server.assets_path + "*", serveStatic]; + app.modules.server.get(path, handler); + } + await config.onBuilt?.(app); + if (adminOptions !== false) { + app.registerAdminController(adminOptions); + } }, "sync", ); + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); } - await config.beforeBuild?.(app); - await app.build(config.buildConfig); - - return app; -} - -export async function createRuntimeApp( - { serveStatic, adminOptions, ...config }: RuntimeBkndConfig, - env?: Env, -): Promise { - const app = App.create(makeConfig(config, env)); - - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async () => { - if (serveStatic) { - const [path, handler] = Array.isArray(serveStatic) - ? serveStatic - : [$config.server.assets_path + "*", serveStatic]; - app.modules.server.get(path, handler); - } - - await config.onBuilt?.(app); - if (adminOptions !== false) { - app.registerAdminController(adminOptions); - } - }, - "sync", - ); - - await config.beforeBuild?.(app); - await app.build(config.buildConfig); - return app; } diff --git a/app/src/adapter/nextjs/nextjs.adapter.spec.ts b/app/src/adapter/nextjs/nextjs.adapter.spec.ts new file mode 100644 index 0000000..8e3f2e4 --- /dev/null +++ b/app/src/adapter/nextjs/nextjs.adapter.spec.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as nextjs from "./nextjs.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import type { NextjsBkndConfig } from "./nextjs.adapter"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("nextjs adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: nextjs.getApp, + makeHandler: nextjs.serve, + }); +}); diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 32ff102..2b3b829 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,36 +1,19 @@ -import type { App } from "bknd"; -import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { isNode } from "core/utils"; +import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter"; +import { isNode } from "bknd/utils"; +import type { NextApiRequest } from "next"; -export type NextjsBkndConfig = FrameworkBkndConfig & { +type NextjsEnv = NextApiRequest["env"]; + +export type NextjsBkndConfig = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; }; -type NextjsContext = { - env: Record; -}; - -let app: App; -let building: boolean = false; - -export async function getApp( - config: NextjsBkndConfig, - args?: Args, +export async function getApp( + config: NextjsBkndConfig, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - if (building) { - while (building) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - if (app) return app; - } - - building = true; - if (!app) { - app = await createFrameworkApp(config, args); - await app.build(); - } - building = false; - return app; + return await createFrameworkApp(config, args ?? (process.env as Env), opts); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -56,11 +39,13 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ }); } -export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { +export function serve( + { cleanRequest, ...config }: NextjsBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, +) { return async (req: Request) => { - if (!app) { - app = await getApp(config, { env: process.env ?? {} }); - } + const app = await getApp(config, args, opts); const request = getCleanRequest(req, cleanRequest); return app.fetch(request); }; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index 5d71d8c..c009c07 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,12 +1,19 @@ import { registries } from "bknd"; -import { - type LocalAdapterConfig, - StorageLocalAdapter, -} from "../../media/storage/adapters/StorageLocalAdapter"; +import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter"; export * from "./node.adapter"; export { StorageLocalAdapter, type LocalAdapterConfig }; +export { nodeTestRunner } from "./test"; +let registered = false; export function registerLocalMediaAdapter() { - registries.media.register("local", StorageLocalAdapter); + if (!registered) { + registries.media.register("local", StorageLocalAdapter); + registered = true; + } + + return (config: Partial = {}) => { + const adapter = new StorageLocalAdapter(config); + return adapter.toJSON(true); + }; } diff --git a/app/src/adapter/node/node.adapter.native-spec.ts b/app/src/adapter/node/node.adapter.native-spec.ts new file mode 100644 index 0000000..c4ece3b --- /dev/null +++ b/app/src/adapter/node/node.adapter.native-spec.ts @@ -0,0 +1,15 @@ +import { describe, before, after } from "node:test"; +import * as node from "./node.adapter"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { nodeTestRunner } from "adapter/node"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +before(() => disableConsoleLog()); +after(enableConsoleLog); + +describe("node adapter", () => { + adapterTestSuite(nodeTestRunner, { + makeApp: node.createApp, + makeHandler: node.createHandler, + }); +}); diff --git a/app/src/adapter/node/node.adapter.spec.ts b/app/src/adapter/node/node.adapter.spec.ts new file mode 100644 index 0000000..4050c68 --- /dev/null +++ b/app/src/adapter/node/node.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as node from "./node.adapter"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("node adapter (bun)", () => { + adapterTestSuite(bunTestRunner, { + makeApp: node.createApp, + makeHandler: node.createHandler, + }); +}); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 97a8b82..816eb92 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -2,11 +2,11 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { registerLocalMediaAdapter } from "adapter/node/index"; -import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { config as $config } from "bknd/core"; -export type NodeBkndConfig = RuntimeBkndConfig & { +type NodeEnv = NodeJS.ProcessEnv; +export type NodeBkndConfig = RuntimeBkndConfig & { port?: number; hostname?: string; listener?: Parameters[1]; @@ -14,14 +14,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & { relativeDistPath?: string; }; -export function serve({ - distPath, - relativeDistPath, - port = $config.server.default_port, - hostname, - listener, - ...config -}: NodeBkndConfig = {}) { +export async function createApp( + { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { const root = path.relative( process.cwd(), path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), @@ -30,23 +27,39 @@ export function serve({ console.warn("relativeDistPath is deprecated, please use distPath instead"); } - let app: App; + registerLocalMediaAdapter(); + return await createRuntimeApp( + { + ...config, + serveStatic: serveStatic({ root }), + }, + // @ts-ignore + args ?? { env: process.env }, + opts, + ); +} +export function createHandler( + config: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return async (req: Request) => { + const app = await createApp(config, args ?? (process.env as Env), opts); + return app.fetch(req); + }; +} + +export function serve( + { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { honoServe( { port, hostname, - fetch: async (req: Request) => { - if (!app) { - registerLocalMediaAdapter(); - app = await createRuntimeApp({ - ...config, - serveStatic: serveStatic({ root }), - }); - } - - return app.fetch(req); - }, + fetch: createHandler(config, args, opts), }, (connInfo) => { console.log(`Server is running on http://localhost:${connInfo.port}`); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts new file mode 100644 index 0000000..2177ce8 --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts @@ -0,0 +1,17 @@ +import { describe } from "node:test"; +import { StorageLocalAdapter, nodeTestRunner } from "adapter/node"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +describe("StorageLocalAdapter (node)", async () => { + const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); + const buffer = readFileSync(path.join(basePath, "image.png")); + const file = new File([buffer], "image.png", { type: "image/png" }); + + const adapter = new StorageLocalAdapter({ + path: path.join(basePath, "tmp"), + }); + + await adapterTestSuite(nodeTestRunner, adapter, file); +}); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts new file mode 100644 index 0000000..b231c15 --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts @@ -0,0 +1,15 @@ +import { describe, test, expect } from "bun:test"; +import { StorageLocalAdapter } from "./StorageLocalAdapter"; +// @ts-ignore +import { assetsPath, assetsTmpPath } from "../../../../__test__/helper"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +describe("StorageLocalAdapter (bun)", async () => { + const adapter = new StorageLocalAdapter({ + path: assetsTmpPath, + }); + + const file = Bun.file(`${assetsPath}/image.png`); + await adapterTestSuite(bunTestRunner, adapter, file); +}); diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts similarity index 87% rename from app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts rename to app/src/adapter/node/storage/StorageLocalAdapter.ts index 9a1a21c..8a44f40 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -1,26 +1,21 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { type Static, Type, isFile, parse } from "core/utils"; -import type { - FileBody, - FileListObject, - FileMeta, - FileUploadPayload, - StorageAdapter, -} from "../../Storage"; -import { guess } from "../../mime-types-tiny"; +import { type Static, Type, isFile, parse } from "bknd/utils"; +import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media"; +import { StorageAdapter, guessMimeType as guess } from "bknd/media"; export const localAdapterConfig = Type.Object( { path: Type.String({ default: "./" }), }, - { title: "Local", description: "Local file system storage" }, + { title: "Local", description: "Local file system storage", additionalProperties: false }, ); export type LocalAdapterConfig = Static; -export class StorageLocalAdapter implements StorageAdapter { +export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; - constructor(config: any) { + constructor(config: Partial = {}) { + super(); this.config = parse(localAdapterConfig, config); } diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts new file mode 100644 index 0000000..992cbee --- /dev/null +++ b/app/src/adapter/node/test.ts @@ -0,0 +1,99 @@ +import nodeAssert from "node:assert/strict"; +import { test } from "node:test"; +import type { Matcher, Test, TestFn, TestRunner } from "core/test"; + +// Track mock function calls +const mockCalls = new WeakMap(); +function createMockFunction any>(fn: T): T { + const mockFn = (...args: Parameters) => { + const currentCalls = mockCalls.get(mockFn) || 0; + mockCalls.set(mockFn, currentCalls + 1); + return fn(...args); + }; + return mockFn as T; +} + +const nodeTestMatcher = (actual: T, parentFailMsg?: string) => + ({ + toEqual: (expected: T, failMsg = parentFailMsg) => { + nodeAssert.deepEqual(actual, expected, failMsg); + }, + toBe: (expected: T, failMsg = parentFailMsg) => { + nodeAssert.strictEqual(actual, expected, failMsg); + }, + toBeString: (failMsg = parentFailMsg) => { + nodeAssert.strictEqual(typeof actual, "string", failMsg); + }, + toBeUndefined: (failMsg = parentFailMsg) => { + nodeAssert.strictEqual(actual, undefined, failMsg); + }, + toBeDefined: (failMsg = parentFailMsg) => { + nodeAssert.notStrictEqual(actual, undefined, failMsg); + }, + toBeOneOf: (expected: T | Array | Iterable, failMsg = parentFailMsg) => { + const e = Array.isArray(expected) ? expected : [expected]; + nodeAssert.ok(e.includes(actual), failMsg); + }, + toHaveBeenCalled: (failMsg = parentFailMsg) => { + const calls = mockCalls.get(actual as Function) || 0; + nodeAssert.ok(calls > 0, failMsg || "Expected function to have been called at least once"); + }, + toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => { + const calls = mockCalls.get(actual as Function) || 0; + nodeAssert.strictEqual( + calls, + expected, + failMsg || `Expected function to have been called ${expected} times`, + ); + }, + }) satisfies Matcher; + +const nodeTestResolverProxy = ( + actual: Promise, + handler: { resolve?: any; reject?: any }, +) => { + return new Proxy( + {}, + { + get: (_, prop) => { + if (prop === "then") { + return actual.then(handler.resolve, handler.reject); + } + return actual; + }, + }, + ) as Matcher>; +}; + +function nodeTest(label: string, fn: TestFn, options?: any) { + return test(label, fn as any); +} +nodeTest.if = (condition: boolean): Test => { + if (condition) { + return nodeTest; + } + return (() => {}) as any; +}; +nodeTest.skip = (label: string, fn: TestFn) => { + return test.skip(label, fn as any); +}; +nodeTest.skipIf = (condition: boolean): Test => { + if (condition) { + return (() => {}) as any; + } + return nodeTest; +}; + +export const nodeTestRunner: TestRunner = { + test: nodeTest, + mock: createMockFunction, + expect: (actual?: T, failMsg?: string) => ({ + ...nodeTestMatcher(actual, failMsg), + resolves: nodeTestResolverProxy(actual as Promise, { + resolve: (r) => nodeTestMatcher(r, failMsg), + }), + rejects: nodeTestResolverProxy(actual as Promise, { + reject: (r) => nodeTestMatcher(r, failMsg), + }), + }), +}; diff --git a/app/src/adapter/react-router/react-router.adapter.spec.ts b/app/src/adapter/react-router/react-router.adapter.spec.ts new file mode 100644 index 0000000..25ef895 --- /dev/null +++ b/app/src/adapter/react-router/react-router.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as rr from "./react-router.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("react-router adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: rr.getApp, + makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }), + }); +}); diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index 7e796c6..4474509 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -1,39 +1,26 @@ -import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import type { FrameworkOptions } from "adapter"; -type ReactRouterContext = { +type ReactRouterEnv = NodeJS.ProcessEnv; +type ReactRouterFunctionArgs = { request: Request; }; -export type ReactRouterBkndConfig = FrameworkBkndConfig; +export type ReactRouterBkndConfig = FrameworkBkndConfig; -let app: App; -let building: boolean = false; - -export async function getApp( - config: ReactRouterBkndConfig, - args?: Args, +export async function getApp( + config: ReactRouterBkndConfig, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - if (building) { - while (building) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - if (app) return app; - } - - building = true; - if (!app) { - app = await createFrameworkApp(config, args); - await app.build(); - } - building = false; - return app; + return await createFrameworkApp(config, args ?? process.env, opts); } -export function serve( - config: ReactRouterBkndConfig = {}, +export function serve( + config: ReactRouterBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - return async (args: Args) => { - app = await getApp(config, args); - return app.fetch(args.request); + return async (fnArgs: ReactRouterFunctionArgs) => { + return (await getApp(config, args, opts)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index bb7eb59..84d7396 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,18 +1,24 @@ import { serveStatic } from "@hono/node-server/serve-static"; -import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; +import { + type DevServerOptions, + default as honoViteDevServer, +} from "@hono/vite-dev-server"; import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { + type RuntimeBkndConfig, + createRuntimeApp, + type FrameworkOptions, +} from "bknd/adapter"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { devServerConfig } from "./dev-server-config"; -export type ViteBkndConfig = RuntimeBkndConfig & { - mode?: "cached" | "fresh"; - setAdminHtml?: boolean; - forceDev?: boolean | { mainPath: string }; - html?: string; -}; +export type ViteEnv = NodeJS.ProcessEnv; +export type ViteBkndConfig = RuntimeBkndConfig & {}; -export function addViteScript(html: string, addBkndContext: boolean = true) { +export function addViteScript( + html: string, + addBkndContext: boolean = true, +) { return html.replace( "", `