@cosmicdrift/kumiko-dev-server 0.329.0 → 0.330.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/bin/kumiko-build.ts +3 -2
- package/bin/kumiko-dev.ts +1 -1
- package/bin/kumiko-init-deploy.ts +1 -1
- package/bin/kumiko-schema-check.ts +4 -1
- package/dist/build-server-bundle.d.ts +31 -0
- package/dist/build-server-bundle.js +282 -0
- package/dist/build.d.ts +2 -0
- package/dist/build.js +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +9 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/render.d.ts +55 -0
- package/dist/codegen/render.js +273 -0
- package/dist/codegen/run-codegen.d.ts +20 -0
- package/dist/codegen/run-codegen.js +219 -0
- package/dist/codegen/scan-events.d.ts +76 -0
- package/dist/codegen/scan-events.js +447 -0
- package/dist/codegen/watch.d.ts +33 -0
- package/dist/codegen/watch.js +112 -0
- package/dist/compose-stacks.d.ts +60 -0
- package/dist/compose-stacks.js +127 -0
- package/dist/crash-tracker.d.ts +17 -0
- package/dist/crash-tracker.js +37 -0
- package/dist/create-kumiko-server.d.ts +195 -0
- package/dist/create-kumiko-server.js +1040 -0
- package/dist/env-schema.d.ts +15 -0
- package/dist/env-schema.js +60 -0
- package/dist/few-shot-corpus.d.ts +72 -0
- package/dist/few-shot-corpus.js +262 -0
- package/dist/framework-version.d.ts +1 -0
- package/dist/framework-version.js +31 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +33 -0
- package/dist/init-deploy-cli.d.ts +10 -0
- package/dist/init-deploy-cli.js +111 -0
- package/dist/kebab.d.ts +1 -0
- package/{src/kebab.ts → dist/kebab.js} +2 -3
- package/dist/run-dev-app.d.ts +174 -0
- package/dist/run-dev-app.js +403 -0
- package/dist/scaffold-app-feature.d.ts +16 -0
- package/dist/scaffold-app-feature.js +240 -0
- package/dist/scaffold-app.d.ts +45 -0
- package/dist/scaffold-app.js +852 -0
- package/dist/scaffold-demo-tasks.d.ts +8 -0
- package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
- package/dist/scaffold-deploy.d.ts +92 -0
- package/dist/scaffold-deploy.js +254 -0
- package/dist/scaffold-feature.d.ts +26 -0
- package/dist/scaffold-feature.js +163 -0
- package/dist/schema-apply.d.ts +11 -0
- package/dist/schema-apply.js +95 -0
- package/dist/schema-check-core.d.ts +2 -0
- package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
- package/dist/schema-tables-auth-mode.d.ts +2 -0
- package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
- package/dist/schema-tables-minimal.d.ts +2 -0
- package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
- package/dist/setup-test-stack-from-features.d.ts +11 -0
- package/dist/setup-test-stack-from-features.js +42 -0
- package/dist/welcome-banner.d.ts +15 -0
- package/dist/welcome-banner.js +27 -0
- package/package.json +71 -24
- package/src/changes.json +6 -0
- package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
- package/src/__tests__/build-server-bundle.test.ts +0 -228
- package/src/__tests__/classify-change.test.ts +0 -87
- package/src/__tests__/compose-stacks.test.ts +0 -273
- package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
- package/src/__tests__/crash-tracker.test.ts +0 -89
- package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
- package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
- package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
- package/src/__tests__/discover-format.test.ts +0 -32
- package/src/__tests__/env-schema.integration.test.ts +0 -248
- package/src/__tests__/env-schema.test.ts +0 -132
- package/src/__tests__/few-shot-corpus.test.ts +0 -316
- package/src/__tests__/framework-version.test.ts +0 -45
- package/src/__tests__/init-deploy-cli.test.ts +0 -113
- package/src/__tests__/kumiko-schema-check.test.ts +0 -56
- package/src/__tests__/merge-extra-context.test.ts +0 -67
- package/src/__tests__/resolve-stylesheet.test.ts +0 -132
- package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
- package/src/__tests__/run-dev-app.integration.test.ts +0 -290
- package/src/__tests__/runtime-classification.test.ts +0 -26
- package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
- package/src/__tests__/scaffold-app-feature.test.ts +0 -216
- package/src/__tests__/scaffold-app.test.ts +0 -630
- package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
- package/src/__tests__/scaffold-deploy.test.ts +0 -464
- package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
- package/src/__tests__/scaffold-feature.test.ts +0 -143
- package/src/__tests__/schema-apply.integration.test.ts +0 -191
- package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
- package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
- package/src/__tests__/walkthrough.integration.test.ts +0 -140
- package/src/__tests__/welcome-banner.test.ts +0 -42
- package/src/build-server-bundle.ts +0 -346
- package/src/build.ts +0 -28
- package/src/codegen/__tests__/render-codegen.test.ts +0 -100
- package/src/codegen/__tests__/run-codegen.test.ts +0 -665
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
- package/src/codegen/__tests__/watch.test.ts +0 -222
- package/src/codegen/index.ts +0 -19
- package/src/codegen/render.ts +0 -305
- package/src/codegen/run-codegen.ts +0 -278
- package/src/codegen/scan-events.ts +0 -565
- package/src/codegen/watch.ts +0 -140
- package/src/compose-stacks.ts +0 -193
- package/src/crash-tracker.ts +0 -56
- package/src/create-kumiko-server.ts +0 -1397
- package/src/env-schema.ts +0 -72
- package/src/few-shot-corpus.ts +0 -370
- package/src/framework-version.ts +0 -39
- package/src/index.ts +0 -105
- package/src/init-deploy-cli.ts +0 -148
- package/src/run-dev-app.ts +0 -694
- package/src/scaffold-app-feature.ts +0 -283
- package/src/scaffold-app.ts +0 -1033
- package/src/scaffold-deploy.ts +0 -393
- package/src/scaffold-feature.ts +0 -226
- package/src/schema-apply.ts +0 -123
- package/src/setup-test-stack-from-features.ts +0 -80
- package/src/welcome-banner.ts +0 -43
|
@@ -1,1007 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
ExtraRouteRejection,
|
|
7
|
-
NO_ROUTE_MATCH_HEADER_NAME,
|
|
8
|
-
requestContext,
|
|
9
|
-
signatureRoute,
|
|
10
|
-
} from "@cosmicdrift/kumiko-framework/api";
|
|
11
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
-
import {
|
|
13
|
-
createBooleanField,
|
|
14
|
-
createEntity,
|
|
15
|
-
createTextField,
|
|
16
|
-
defineFeature,
|
|
17
|
-
type TenantId,
|
|
18
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
-
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
-
import { getSetCookieValue } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
-
import * as z from "zod";
|
|
22
|
-
import {
|
|
23
|
-
createKumikoServer,
|
|
24
|
-
type KumikoServerHandle,
|
|
25
|
-
PROD_BUNDLES_ENV,
|
|
26
|
-
STYLESHEET_WATCH_ENV,
|
|
27
|
-
} from "../create-kumiko-server";
|
|
28
|
-
|
|
29
|
-
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
30
|
-
|
|
31
|
-
// Integration-Test: bootet createKumikoServer mit echtem Postgres,
|
|
32
|
-
// echtem Redis, echter Kumiko-Pipeline. Treibt den fetch-Handler
|
|
33
|
-
// direkt an (nicht über Bun.serve + Socket), weil vitest unter Node
|
|
34
|
-
// läuft. Unter Bun würde derselbe Handler identisch antworten —
|
|
35
|
-
// das Routing ist runtime-neutral.
|
|
36
|
-
|
|
37
|
-
const probeEntity = createEntity({
|
|
38
|
-
fields: {
|
|
39
|
-
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
40
|
-
done: createBooleanField(),
|
|
41
|
-
},
|
|
42
|
-
table: "kumiko_server_probe",
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const probeFeature = defineFeature("dev-server-probe", (r) => {
|
|
46
|
-
r.entity("probe", probeEntity);
|
|
47
|
-
// Anonymous query for hostDispatch's deps.systemQuery — proves the
|
|
48
|
-
// dev path gets the same anonymous-role dispatch-deps shape as prod.
|
|
49
|
-
r.queryHandler({
|
|
50
|
-
name: "ping",
|
|
51
|
-
schema: z.object({}),
|
|
52
|
-
access: { roles: ["anonymous"] },
|
|
53
|
-
handler: async () => ({ pong: true }),
|
|
54
|
-
});
|
|
55
|
-
// Echoes the resolved client IP — proves a real Bun.serve socket address
|
|
56
|
-
// reaches requestContext.ip through handleFetch (fw#3260 addendum: every
|
|
57
|
-
// other test drives h.fetch()/handle.fetch() directly, never Bun.serve's
|
|
58
|
-
// real socket).
|
|
59
|
-
r.queryHandler({
|
|
60
|
-
name: "whoami",
|
|
61
|
-
schema: z.object({}),
|
|
62
|
-
access: { roles: ["anonymous"] },
|
|
63
|
-
handler: async () => ({ ip: requestContext.get()?.ip }),
|
|
64
|
-
});
|
|
65
|
-
// SystemAdmin-gated write — Ziel der entry:"signature" / `wire`-Tests
|
|
66
|
-
// (252/2): Echo von user.tenantId + roles beweist Dispatch durch
|
|
67
|
-
// den echten Dispatcher (Zod + Access-Check) mit Ziel-Tenant-SystemUser.
|
|
68
|
-
r.writeHandler({
|
|
69
|
-
name: "probe-write",
|
|
70
|
-
schema: z.object({ note: z.string() }),
|
|
71
|
-
access: { roles: ["SystemAdmin"] },
|
|
72
|
-
handler: async (event) => ({
|
|
73
|
-
isSuccess: true as const,
|
|
74
|
-
data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
|
|
75
|
-
}),
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
let handle: KumikoServerHandle | undefined;
|
|
80
|
-
|
|
81
|
-
afterEach(async () => {
|
|
82
|
-
if (handle) {
|
|
83
|
-
await handle.stop();
|
|
84
|
-
handle = undefined;
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
async function boot(): Promise<KumikoServerHandle> {
|
|
89
|
-
handle = await createKumikoServer({
|
|
90
|
-
features: [probeFeature],
|
|
91
|
-
port: 0,
|
|
92
|
-
installSignalHandlers: false,
|
|
93
|
-
});
|
|
94
|
-
return handle;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
describe("createKumikoServer", () => {
|
|
98
|
-
test("bootet den Kumiko-Stack + legt die Feature-Tables an", async () => {
|
|
99
|
-
const h = await boot();
|
|
100
|
-
const rows = await asRawClient(h.stack.db).unsafe(
|
|
101
|
-
`SELECT to_regclass('public.kumiko_server_probe') IS NOT NULL AS "exists"`,
|
|
102
|
-
);
|
|
103
|
-
expect((rows as Array<Record<string, unknown>>)[0]?.["exists"]).toBe(true);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("GET / → HTML + kumiko_auth/kumiko_csrf Set-Cookie", async () => {
|
|
107
|
-
const h = await boot();
|
|
108
|
-
const res = await h.fetch(new Request("http://localhost/"));
|
|
109
|
-
expect(res.status).toBe(200);
|
|
110
|
-
expect(res.headers.get("content-type")).toMatch(/text\/html/);
|
|
111
|
-
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
112
|
-
expect(setCookie).toMatch(/kumiko_auth=/);
|
|
113
|
-
expect(setCookie).toMatch(/kumiko_csrf=/);
|
|
114
|
-
const body = await res.text();
|
|
115
|
-
expect(body).toMatch(/<div id="root">/);
|
|
116
|
-
// Reload-Snippet wurde in </body> injiziert.
|
|
117
|
-
expect(body).toMatch(/EventSource\("\/_reload"\)/);
|
|
118
|
-
// As a classic script the bundle's top-level declarations become window
|
|
119
|
-
// properties: a dependency exporting `function history()`
|
|
120
|
-
// (prosemirror-history, via tiptap) then replaces window.history and every
|
|
121
|
-
// pushState throws. Prod has always emitted the module form.
|
|
122
|
-
expect(body).toMatch(/<script type="module" src="\/client\.js"><\/script>/);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("GET /client.js → 404 wenn clientEntry fehlt (Route nicht registriert)", async () => {
|
|
126
|
-
// Pre-multi-entry-Refactor lieferte das eine 200 mit leerem Body —
|
|
127
|
-
// war eine Quirky Backwards-Compat. Mit der Multi-Entry-Engine wird
|
|
128
|
-
// /client.js erst registriert wenn ein Entry vorhanden ist (kein
|
|
129
|
-
// entries → kein Bundle-Asset-Path → 404 ist korrekt).
|
|
130
|
-
const h = await boot();
|
|
131
|
-
const res = await h.fetch(new Request("http://localhost/client.js"));
|
|
132
|
-
expect(res.status).toBe(404);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("Single-Entry: clientEntry='client.tsx' liefert Bundle unter /client.js", async () => {
|
|
136
|
-
// Backwards-Compat-Smoke: existierende Samples (designer, ui-walkthrough,
|
|
137
|
-
// beammycar, …) nutzen clientEntry — der normalize-Pfad muss daraus
|
|
138
|
-
// `/client.js` als Asset-Path ableiten. Test injiziert via _buildBundle
|
|
139
|
-
// einen Stub damit der Body deterministisch geprüft werden kann (kein
|
|
140
|
-
// Bun.build unter Node).
|
|
141
|
-
const tmpFile = mkdtempSync(join(tmpdir(), "kumiko-single-it-"));
|
|
142
|
-
const entry = join(tmpFile, "client.tsx");
|
|
143
|
-
writeFileSync(entry, "// noop");
|
|
144
|
-
try {
|
|
145
|
-
handle = await createKumikoServer({
|
|
146
|
-
features: [probeFeature],
|
|
147
|
-
port: 0,
|
|
148
|
-
installSignalHandlers: false,
|
|
149
|
-
clientEntry: entry,
|
|
150
|
-
_buildBundle: async () => ({ js: "// SINGLE-ENTRY-STUB", map: "" }),
|
|
151
|
-
});
|
|
152
|
-
const res = await handle.fetch(new Request("http://localhost/client.js"));
|
|
153
|
-
expect(res.status).toBe(200);
|
|
154
|
-
expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
|
|
155
|
-
expect(await res.text()).toBe("// SINGLE-ENTRY-STUB");
|
|
156
|
-
} finally {
|
|
157
|
-
rmSync(tmpFile, { recursive: true, force: true });
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("GET /client.js.map → 404 wenn kein Sourcemap vorhanden", async () => {
|
|
162
|
-
const h = await boot();
|
|
163
|
-
const res = await h.fetch(new Request("http://localhost/client.js.map"));
|
|
164
|
-
expect(res.status).toBe(404);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("GET /_reload → text/event-stream mit connected-Komment", async () => {
|
|
168
|
-
const h = await boot();
|
|
169
|
-
const res = await h.fetch(new Request("http://localhost/_reload"));
|
|
170
|
-
expect(res.status).toBe(200);
|
|
171
|
-
expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
|
|
172
|
-
const reader = res.body?.getReader();
|
|
173
|
-
expect(reader).toBeDefined();
|
|
174
|
-
if (!reader) return;
|
|
175
|
-
const { value } = await reader.read();
|
|
176
|
-
const chunk = new TextDecoder().decode(value);
|
|
177
|
-
expect(chunk).toMatch(/connected/);
|
|
178
|
-
await reader.cancel();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("unbekannter Pfad → forwarded an den Hono-Stack", async () => {
|
|
182
|
-
const h = await boot();
|
|
183
|
-
// /api/ghost existiert nicht → Hono liefert 404. Der entscheidende
|
|
184
|
-
// Punkt: die Response kommt VOM Stack, nicht vom Dev-Server-Layer.
|
|
185
|
-
// Wir unterscheiden, indem wir prüfen, dass es KEINE HTML- oder
|
|
186
|
-
// event-stream-Response ist — die gehören zum Dev-Server-Layer.
|
|
187
|
-
const res = await h.fetch(new Request("http://localhost/api/ghost"));
|
|
188
|
-
expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
|
|
189
|
-
expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/event-stream/);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("Server-Instanz unter Bun gesetzt, unter Node undefined", async () => {
|
|
193
|
-
const h = await boot();
|
|
194
|
-
if (typeof Bun !== "undefined") {
|
|
195
|
-
expect(h.server).toBeDefined();
|
|
196
|
-
} else {
|
|
197
|
-
expect(h.server).toBeUndefined();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// Multi-Entry-Mode — exerciert clientEntries + hostDispatch (Discriminated-
|
|
203
|
-
// Union). Bun.build wird via _buildBundle gestubbt; der Routing-Pfad
|
|
204
|
-
// (HTML-Dispatch pro Host + Bundle-Routing pro Asset-Path) ist runtime-
|
|
205
|
-
// neutral und wird hier vollständig getrieben. Echte Bundle-Produktion
|
|
206
|
-
// deckt die `kumiko-build CLI`-Suite (build-prod-bundle.integration.ts) ab.
|
|
207
|
-
describe("createKumikoServer (Multi-Entry)", () => {
|
|
208
|
-
let tmpDir = "";
|
|
209
|
-
|
|
210
|
-
afterEach(() => {
|
|
211
|
-
if (tmpDir) {
|
|
212
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
213
|
-
tmpDir = "";
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
async function bootMultiEntry(): Promise<KumikoServerHandle> {
|
|
218
|
-
tmpDir = mkdtempSync(join(tmpdir(), "kumiko-multi-it-"));
|
|
219
|
-
const publicEntry = join(tmpDir, "client-public.tsx");
|
|
220
|
-
const adminEntry = join(tmpDir, "client-admin.tsx");
|
|
221
|
-
const publicHtml = join(tmpDir, "index.html");
|
|
222
|
-
const adminHtml = join(tmpDir, "admin.html");
|
|
223
|
-
writeFileSync(publicEntry, "// public");
|
|
224
|
-
writeFileSync(adminEntry, "// admin");
|
|
225
|
-
writeFileSync(
|
|
226
|
-
publicHtml,
|
|
227
|
-
`<!doctype html><html><body><div id="root"></div><script src="/client-public.js"></script>PUBLIC-HTML</body></html>`,
|
|
228
|
-
);
|
|
229
|
-
writeFileSync(
|
|
230
|
-
adminHtml,
|
|
231
|
-
`<!doctype html><html><body><div id="root"></div><script src="/client-admin.js"></script>ADMIN-HTML</body></html>`,
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
return createKumikoServer({
|
|
235
|
-
features: [probeFeature],
|
|
236
|
-
port: 0,
|
|
237
|
-
installSignalHandlers: false,
|
|
238
|
-
clientEntries: [
|
|
239
|
-
{ name: "public", sourceFile: publicEntry, htmlPath: publicHtml },
|
|
240
|
-
{ name: "admin", sourceFile: adminEntry, htmlPath: adminHtml },
|
|
241
|
-
],
|
|
242
|
-
// Stub: der Bundle-Inhalt enthält den Entry-Namen damit der Test
|
|
243
|
-
// beweisen kann dass /client-public.js ≠ /client-admin.js. Echtes
|
|
244
|
-
// Bun.build würde unterschiedlich-gehashte Bundles produzieren —
|
|
245
|
-
// wir simulieren das mit deterministischen Markern.
|
|
246
|
-
_buildBundle: async (sourceFile) => {
|
|
247
|
-
if (sourceFile === publicEntry) {
|
|
248
|
-
return { js: "// PUBLIC-BUNDLE", map: "" };
|
|
249
|
-
}
|
|
250
|
-
if (sourceFile === adminEntry) {
|
|
251
|
-
return { js: "// ADMIN-BUNDLE", map: "" };
|
|
252
|
-
}
|
|
253
|
-
throw new Error(`unexpected entry: ${sourceFile}`);
|
|
254
|
-
},
|
|
255
|
-
hostDispatch: (req) => {
|
|
256
|
-
const host = (req.headers.get("host") ?? "").split(":")[0]?.toLowerCase() ?? "";
|
|
257
|
-
if (host === "apex.test") return { kind: "not-found" };
|
|
258
|
-
if (host === "old.test") return { kind: "redirect", to: "https://new.test/" };
|
|
259
|
-
if (host.startsWith("admin.")) {
|
|
260
|
-
return { kind: "html", entryName: "admin" };
|
|
261
|
-
}
|
|
262
|
-
return { kind: "html", entryName: "public" };
|
|
263
|
-
},
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
test("HTML-Dispatch: admin-Host bekommt admin.html, sonst index.html", async () => {
|
|
268
|
-
handle = await bootMultiEntry();
|
|
269
|
-
|
|
270
|
-
const publicRes = await handle.fetch(
|
|
271
|
-
new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
|
|
272
|
-
);
|
|
273
|
-
const publicBody = await publicRes.text();
|
|
274
|
-
expect(publicBody).toMatch(/PUBLIC-HTML/);
|
|
275
|
-
expect(publicBody).not.toMatch(/ADMIN-HTML/);
|
|
276
|
-
|
|
277
|
-
const adminRes = await handle.fetch(
|
|
278
|
-
new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
|
|
279
|
-
);
|
|
280
|
-
const adminBody = await adminRes.text();
|
|
281
|
-
expect(adminBody).toMatch(/ADMIN-HTML/);
|
|
282
|
-
expect(adminBody).not.toMatch(/PUBLIC-HTML/);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test("Bundle-Routing: /client-public.js ≠ /client-admin.js", async () => {
|
|
286
|
-
handle = await bootMultiEntry();
|
|
287
|
-
|
|
288
|
-
const publicJs = await handle.fetch(new Request("http://status.localhost/client-public.js"));
|
|
289
|
-
expect(publicJs.status).toBe(200);
|
|
290
|
-
expect(publicJs.headers.get("content-type")).toMatch(/application\/javascript/);
|
|
291
|
-
expect(await publicJs.text()).toBe("// PUBLIC-BUNDLE");
|
|
292
|
-
|
|
293
|
-
const adminJs = await handle.fetch(new Request("http://admin.localhost/client-admin.js"));
|
|
294
|
-
expect(adminJs.status).toBe(200);
|
|
295
|
-
expect(await adminJs.text()).toBe("// ADMIN-BUNDLE");
|
|
296
|
-
|
|
297
|
-
// Cross-routing existiert NICHT — /client.js (Single-Entry-Pfad)
|
|
298
|
-
// ist im Multi-Mode kein registrierter Asset-Path.
|
|
299
|
-
const noFallback = await handle.fetch(new Request("http://localhost/client.js"));
|
|
300
|
-
expect(noFallback.status).toBe(404);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test("HTML never carries the schema; GET /api/schema needs the auto-minted cookie (kumiko-framework#3314)", async () => {
|
|
304
|
-
handle = await bootMultiEntry();
|
|
305
|
-
|
|
306
|
-
const publicRes = await handle.fetch(
|
|
307
|
-
new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
|
|
308
|
-
);
|
|
309
|
-
expect(await publicRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
|
|
310
|
-
|
|
311
|
-
const adminRes = await handle.fetch(
|
|
312
|
-
new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
|
|
313
|
-
);
|
|
314
|
-
expect(await adminRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
|
|
315
|
-
|
|
316
|
-
const authCookie = getSetCookieValue(adminRes, "kumiko_auth");
|
|
317
|
-
expect(authCookie).toBeDefined();
|
|
318
|
-
|
|
319
|
-
const unauthenticated = await handle.fetch(new Request("http://admin.localhost/api/schema"));
|
|
320
|
-
expect(unauthenticated.status).toBe(401);
|
|
321
|
-
|
|
322
|
-
const authenticatedRes = await handle.fetch(
|
|
323
|
-
new Request("http://admin.localhost/api/schema", {
|
|
324
|
-
headers: { cookie: `kumiko_auth=${authCookie}` },
|
|
325
|
-
}),
|
|
326
|
-
);
|
|
327
|
-
expect(authenticatedRes.status).toBe(200);
|
|
328
|
-
const schema = await authenticatedRes.json();
|
|
329
|
-
// Guards against an empty features array making the #2062 `.some(...)`
|
|
330
|
-
// assertion below vacuously false.
|
|
331
|
-
expect(schema.features.length).toBeGreaterThan(0);
|
|
332
|
-
|
|
333
|
-
// #2062: setupTestStack always wires an in-memory SearchAdapter
|
|
334
|
-
// (test-stack.ts), so the dev-server call site's
|
|
335
|
-
// `searchAdapterMissing: !stack.context.searchAdapter` must read false —
|
|
336
|
-
// proving the flag actually flows from stack.context through to the
|
|
337
|
-
// schema served by GET /api/schema.
|
|
338
|
-
const anyFeatureMissingAdapter = schema.features.some(
|
|
339
|
-
(f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
|
|
340
|
-
);
|
|
341
|
-
expect(anyFeatureMissingAdapter).toBe(false);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
test("hostDispatch redirect: liefert 302 mit Location-Header", async () => {
|
|
345
|
-
handle = await bootMultiEntry();
|
|
346
|
-
|
|
347
|
-
const res = await handle.fetch(
|
|
348
|
-
new Request("http://old.test/", { headers: { host: "old.test" } }),
|
|
349
|
-
);
|
|
350
|
-
expect(res.status).toBe(302);
|
|
351
|
-
expect(res.headers.get("location")).toBe("https://new.test/");
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("hostDispatch not-found: liefert 404", async () => {
|
|
355
|
-
handle = await bootMultiEntry();
|
|
356
|
-
|
|
357
|
-
const res = await handle.fetch(
|
|
358
|
-
new Request("http://apex.test/", { headers: { host: "apex.test" } }),
|
|
359
|
-
);
|
|
360
|
-
expect(res.status).toBe(404);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test("hostDispatch: async fn erhält deps.systemQuery, 'not-found' → 404 (kumiko-framework#3050 dev-Pendant)", async () => {
|
|
364
|
-
tmpDir = mkdtempSync(join(tmpdir(), "kumiko-multi-it-"));
|
|
365
|
-
const publicEntry = join(tmpDir, "client-public.tsx");
|
|
366
|
-
writeFileSync(publicEntry, "// public");
|
|
367
|
-
let systemQueryPonged = false;
|
|
368
|
-
|
|
369
|
-
handle = await createKumikoServer({
|
|
370
|
-
features: [probeFeature],
|
|
371
|
-
port: 0,
|
|
372
|
-
installSignalHandlers: false,
|
|
373
|
-
clientEntries: [{ name: "public", sourceFile: publicEntry }],
|
|
374
|
-
_buildBundle: async () => ({ js: "// PUBLIC-BUNDLE", map: "" }),
|
|
375
|
-
hostDispatch: async (_req, deps) => {
|
|
376
|
-
const result = await deps.systemQuery("dev-server-probe:query:ping", {}, TENANT_ID);
|
|
377
|
-
systemQueryPonged = (result as { pong: boolean }).pong === true; // @cast-boundary engine-payload
|
|
378
|
-
return { kind: "not-found" };
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
const res = await handle.fetch(new Request("http://localhost/"));
|
|
383
|
-
expect(res.status).toBe(404);
|
|
384
|
-
expect(systemQueryPonged).toBe(true);
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// 252/2: the dev path shares extra-routes-deps.ts with runProdApp —
|
|
389
|
-
// here the analogous proof against createKumikoServer, with `wire` for the
|
|
390
|
-
// registry/dispatchSystemWrite check and an entry:"signature" route for
|
|
391
|
-
// the webhook dispatch.
|
|
392
|
-
describe("createKumikoServer extraRoutes / wire deps", () => {
|
|
393
|
-
test("wire: dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
|
|
394
|
-
const tenantId = "00000000-0000-4000-8000-000000000042";
|
|
395
|
-
let registryHasProbe = false;
|
|
396
|
-
handle = await createKumikoServer({
|
|
397
|
-
features: [probeFeature],
|
|
398
|
-
port: 0,
|
|
399
|
-
installSignalHandlers: false,
|
|
400
|
-
extraRoutes: [
|
|
401
|
-
signatureRoute({
|
|
402
|
-
method: "POST",
|
|
403
|
-
path: "/webhook-probe",
|
|
404
|
-
entry: "signature",
|
|
405
|
-
verify: async (req) => {
|
|
406
|
-
if (req.headers["x-probe-signature"] !== "test-secret") {
|
|
407
|
-
throw new ExtraRouteRejection(401, { error: "bad-signature" });
|
|
408
|
-
}
|
|
409
|
-
},
|
|
410
|
-
handler: async (c, _verified, deps) => {
|
|
411
|
-
const result = await deps.dispatchSystemWrite({
|
|
412
|
-
handlerQn: "dev-server-probe:write:probe-write",
|
|
413
|
-
payload: { note: "from-webhook" },
|
|
414
|
-
tenantId: tenantId as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
415
|
-
});
|
|
416
|
-
return c.json(result);
|
|
417
|
-
},
|
|
418
|
-
}),
|
|
419
|
-
],
|
|
420
|
-
wire: (deps) => {
|
|
421
|
-
registryHasProbe = deps.registry.features.has("dev-server-probe");
|
|
422
|
-
},
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
expect(registryHasProbe).toBe(true);
|
|
426
|
-
|
|
427
|
-
const res = await handle.fetch(
|
|
428
|
-
new Request("http://test/webhook-probe", {
|
|
429
|
-
method: "POST",
|
|
430
|
-
headers: { "x-probe-signature": "test-secret" },
|
|
431
|
-
}),
|
|
432
|
-
);
|
|
433
|
-
expect(res.status).toBe(200);
|
|
434
|
-
const body = (await res.json()) as {
|
|
435
|
-
isSuccess: boolean;
|
|
436
|
-
data?: { tenantSeen: string; roles: string[] };
|
|
437
|
-
};
|
|
438
|
-
expect(body.isSuccess).toBe(true);
|
|
439
|
-
expect(body.data?.tenantSeen).toBe(tenantId);
|
|
440
|
-
expect(body.data?.roles).toContain("SystemAdmin");
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
// framework#1668: extraRoutes must be registered BEFORE onAfterSetup
|
|
445
|
-
// dispatches through stack.http — Hono builds its matcher on first dispatch,
|
|
446
|
-
// and any route registered after that throws. Pins the ordering.
|
|
447
|
-
describe("createKumikoServer — extraRoutes vs. onAfterSetup ordering", () => {
|
|
448
|
-
test("an extraRoutes route stays reachable after onAfterSetup dispatches via stack.http", async () => {
|
|
449
|
-
handle = await createKumikoServer({
|
|
450
|
-
features: [probeFeature],
|
|
451
|
-
port: 0,
|
|
452
|
-
installSignalHandlers: false,
|
|
453
|
-
extraRoutes: [
|
|
454
|
-
{
|
|
455
|
-
method: "GET",
|
|
456
|
-
path: "/probe",
|
|
457
|
-
entry: "anonymous",
|
|
458
|
-
handler: (c) => c.json({ ok: true }),
|
|
459
|
-
},
|
|
460
|
-
],
|
|
461
|
-
onAfterSetup: async (stack) => {
|
|
462
|
-
await stack.http.writeOk(
|
|
463
|
-
"dev-server-probe:write:probe-write",
|
|
464
|
-
{ note: "onAfterSetup" },
|
|
465
|
-
TestUsers.systemAdmin,
|
|
466
|
-
);
|
|
467
|
-
},
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
const res = await handle.fetch(new Request("http://test/probe"));
|
|
471
|
-
expect(res.status).toBe(200);
|
|
472
|
-
});
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => {
|
|
476
|
-
test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => {
|
|
477
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-"));
|
|
478
|
-
const entry = join(tmpDir, "client.tsx");
|
|
479
|
-
writeFileSync(entry, "export const x = 1;\n");
|
|
480
|
-
try {
|
|
481
|
-
handle = await createKumikoServer({
|
|
482
|
-
features: [probeFeature],
|
|
483
|
-
port: 0,
|
|
484
|
-
installSignalHandlers: false,
|
|
485
|
-
clientEntry: entry,
|
|
486
|
-
stylesheet: join(tmpDir, "does-not-exist.css"),
|
|
487
|
-
_buildBundle: async () => ({ js: "// stub", map: "" }),
|
|
488
|
-
});
|
|
489
|
-
const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
|
|
490
|
-
expect(cssRes.status).toBe(404);
|
|
491
|
-
expect(await cssRes.text()).toBe("no stylesheet");
|
|
492
|
-
} finally {
|
|
493
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
describe("createKumikoServer — stylesheet watch disabled via env", () => {
|
|
499
|
-
test("KUMIKO_DEV_STYLESHEET_WATCH=0 serves the built CSS and never spawns a --watch process", async () => {
|
|
500
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-no-watch-"));
|
|
501
|
-
const entryCss = join(tmpDir, "styles.css");
|
|
502
|
-
// Plain CSS, no `@import "tailwindcss"` — the marker property survives
|
|
503
|
-
// the Tailwind CLI/lightningcss pass unchanged, unlike a color value
|
|
504
|
-
// which lightningcss may renormalize.
|
|
505
|
-
writeFileSync(entryCss, ".kumiko-probe { --probe-marker: 42; }\n");
|
|
506
|
-
const previousWatchEnv = process.env[STYLESHEET_WATCH_ENV];
|
|
507
|
-
process.env[STYLESHEET_WATCH_ENV] = "0";
|
|
508
|
-
try {
|
|
509
|
-
handle = await createKumikoServer({
|
|
510
|
-
features: [probeFeature],
|
|
511
|
-
port: 0,
|
|
512
|
-
installSignalHandlers: false,
|
|
513
|
-
stylesheet: entryCss,
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
|
|
517
|
-
expect(cssRes.status).toBe(200);
|
|
518
|
-
const css = await cssRes.text();
|
|
519
|
-
expect(css).toContain("--probe-marker: 42");
|
|
520
|
-
|
|
521
|
-
// -ww: unwrapped, full argv — a truncated args column would make the
|
|
522
|
-
// negative assert below pass vacuously. The pid check proves ps
|
|
523
|
-
// actually returned live process rows on this machine/CI runner.
|
|
524
|
-
const ps = Bun.spawnSync(["ps", "-A", "-ww", "-o", "pid=,args="]);
|
|
525
|
-
const psOutput = ps.stdout.toString();
|
|
526
|
-
const lines = psOutput.split("\n");
|
|
527
|
-
expect(lines.some((line) => line.includes(String(process.pid)))).toBe(true);
|
|
528
|
-
expect(lines.some((line) => line.includes(entryCss) && line.includes("--watch"))).toBe(false);
|
|
529
|
-
} finally {
|
|
530
|
-
if (previousWatchEnv === undefined) delete process.env[STYLESHEET_WATCH_ENV];
|
|
531
|
-
else process.env[STYLESHEET_WATCH_ENV] = previousWatchEnv;
|
|
532
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
533
|
-
}
|
|
534
|
-
});
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
describe("createKumikoServer — real Bun.build (buildClient)", () => {
|
|
538
|
-
test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => {
|
|
539
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-"));
|
|
540
|
-
const entry = join(tmpDir, "client.tsx");
|
|
541
|
-
// Minimal entry Bun.build can emit — no JSX, no imports.
|
|
542
|
-
writeFileSync(entry, "export const ping = 1;\n");
|
|
543
|
-
// Empty dirs matching a glob — exercises expandWatchPatterns without
|
|
544
|
-
// writing files (avoids process.exit(75) from bare .tsx events).
|
|
545
|
-
mkdirSync(join(tmpDir, "pkg-a"));
|
|
546
|
-
mkdirSync(join(tmpDir, "pkg-b"));
|
|
547
|
-
try {
|
|
548
|
-
handle = await createKumikoServer({
|
|
549
|
-
features: [probeFeature],
|
|
550
|
-
port: 0,
|
|
551
|
-
installSignalHandlers: false,
|
|
552
|
-
clientEntry: entry,
|
|
553
|
-
stylesheet: false,
|
|
554
|
-
watchDirs: [join(tmpDir, "pkg-*")],
|
|
555
|
-
});
|
|
556
|
-
const res = await handle.fetch(new Request("http://localhost/client.js"));
|
|
557
|
-
expect(res.status).toBe(200);
|
|
558
|
-
expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
|
|
559
|
-
const body = await res.text();
|
|
560
|
-
expect(body.length).toBeGreaterThan(0);
|
|
561
|
-
expect(body).toMatch(/ping/);
|
|
562
|
-
|
|
563
|
-
const mapRes = await handle.fetch(new Request("http://localhost/client.js.map"));
|
|
564
|
-
expect(mapRes.status).toBe(200);
|
|
565
|
-
expect(await mapRes.text()).toMatch(/"sources"/);
|
|
566
|
-
|
|
567
|
-
// Stop the watcher before teardown rmSync deletes clientEntry out from
|
|
568
|
-
// under it — a bare "client.tsx" delete event classifies as "restart"
|
|
569
|
-
// (classifyChange only special-cases endsWith("/client.tsx")) and
|
|
570
|
-
// process.exit(75) kills the whole bun test runner mid-suite.
|
|
571
|
-
await handle.stop();
|
|
572
|
-
handle = undefined;
|
|
573
|
-
} finally {
|
|
574
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("PROD_BUNDLES_ENV builds a prod-shaped bundle: split chunk served, no sourcemap, NODE_ENV replaced", async () => {
|
|
579
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-prod-"));
|
|
580
|
-
const entry = join(tmpDir, "client.tsx");
|
|
581
|
-
writeFileSync(
|
|
582
|
-
entry,
|
|
583
|
-
`export async function loadLazy() {
|
|
584
|
-
const m = await import("./lazy.ts");
|
|
585
|
-
return m.hello();
|
|
586
|
-
}
|
|
587
|
-
`,
|
|
588
|
-
);
|
|
589
|
-
writeFileSync(
|
|
590
|
-
join(tmpDir, "lazy.ts"),
|
|
591
|
-
`export function hello() {
|
|
592
|
-
return "env=" + process.env.NODE_ENV;
|
|
593
|
-
}
|
|
594
|
-
`,
|
|
595
|
-
);
|
|
596
|
-
const previousProdBundles = process.env[PROD_BUNDLES_ENV];
|
|
597
|
-
process.env[PROD_BUNDLES_ENV] = "1";
|
|
598
|
-
try {
|
|
599
|
-
handle = await createKumikoServer({
|
|
600
|
-
features: [probeFeature],
|
|
601
|
-
port: 0,
|
|
602
|
-
installSignalHandlers: false,
|
|
603
|
-
clientEntry: entry,
|
|
604
|
-
stylesheet: false,
|
|
605
|
-
});
|
|
606
|
-
const res = await handle.fetch(new Request("http://localhost/client.js"));
|
|
607
|
-
expect(res.status).toBe(200);
|
|
608
|
-
const body = await res.text();
|
|
609
|
-
|
|
610
|
-
// Only the dynamic import() call points at the lazy chunk — the
|
|
611
|
-
// entry also statically imports a shared async-loader chunk.
|
|
612
|
-
const chunkMatch = body.match(/import\("(?:\.\/)?(chunk-[\w-]+\.js)"\)/);
|
|
613
|
-
expect(chunkMatch).not.toBeNull();
|
|
614
|
-
const normalizedChunkPath = `/${chunkMatch ? (chunkMatch[1] ?? "") : ""}`;
|
|
615
|
-
|
|
616
|
-
const chunkRes = await handle.fetch(new Request(`http://localhost${normalizedChunkPath}`));
|
|
617
|
-
expect(chunkRes.status).toBe(200);
|
|
618
|
-
expect(chunkRes.headers.get("content-type")).toMatch(/application\/javascript/);
|
|
619
|
-
const chunkBody = await chunkRes.text();
|
|
620
|
-
expect(chunkBody).toContain("env=production");
|
|
621
|
-
|
|
622
|
-
const mapRes = await handle.fetch(new Request("http://localhost/client.js.map"));
|
|
623
|
-
expect(mapRes.status).toBe(404);
|
|
624
|
-
|
|
625
|
-
await handle.stop();
|
|
626
|
-
handle = undefined;
|
|
627
|
-
} finally {
|
|
628
|
-
if (previousProdBundles === undefined) delete process.env[PROD_BUNDLES_ENV];
|
|
629
|
-
else process.env[PROD_BUNDLES_ENV] = previousProdBundles;
|
|
630
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
631
|
-
}
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
describe("createKumikoServer — hot-reload broadcast", () => {
|
|
636
|
-
test("file change under web/ rebuilds and broadcasts SSE reload", async () => {
|
|
637
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-"));
|
|
638
|
-
const entry = join(tmpDir, "client.tsx");
|
|
639
|
-
const webDir = join(tmpDir, "web");
|
|
640
|
-
mkdirSync(webDir);
|
|
641
|
-
writeFileSync(entry, "export const ping = 1;\n");
|
|
642
|
-
|
|
643
|
-
let builds = 0;
|
|
644
|
-
try {
|
|
645
|
-
handle = await createKumikoServer({
|
|
646
|
-
features: [probeFeature],
|
|
647
|
-
port: 0,
|
|
648
|
-
installSignalHandlers: false,
|
|
649
|
-
clientEntry: entry,
|
|
650
|
-
stylesheet: false,
|
|
651
|
-
// Only the entry dir is watched (no extra watchDirs) — a nested
|
|
652
|
-
// web/page.tsx event arrives as "web/page.tsx" → hot-reload.
|
|
653
|
-
// Watching web/ separately would fire bare "page.tsx" → restart
|
|
654
|
-
// → process.exit(75) and kill the test runner.
|
|
655
|
-
_buildBundle: async () => {
|
|
656
|
-
builds += 1;
|
|
657
|
-
return { js: `// build-${builds}`, map: "" };
|
|
658
|
-
},
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
const sseRes = await handle.fetch(new Request("http://localhost/_reload"));
|
|
662
|
-
expect(sseRes.status).toBe(200);
|
|
663
|
-
const reader = sseRes.body?.getReader();
|
|
664
|
-
expect(reader).toBeDefined();
|
|
665
|
-
if (!reader) return;
|
|
666
|
-
|
|
667
|
-
await reader.read(); // drain connected comment
|
|
668
|
-
|
|
669
|
-
const initialBuilds = builds;
|
|
670
|
-
writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n");
|
|
671
|
-
|
|
672
|
-
const readAll = (async () => {
|
|
673
|
-
for (;;) {
|
|
674
|
-
const { value, done } = await reader.read();
|
|
675
|
-
if (done) return false;
|
|
676
|
-
if (new TextDecoder().decode(value).includes("event: reload")) return true;
|
|
677
|
-
}
|
|
678
|
-
})();
|
|
679
|
-
const sawReload = await Promise.race([readAll, Bun.sleep(3000).then(() => false)]);
|
|
680
|
-
await reader.cancel();
|
|
681
|
-
|
|
682
|
-
expect(builds).toBeGreaterThan(initialBuilds);
|
|
683
|
-
expect(sawReload).toBe(true);
|
|
684
|
-
|
|
685
|
-
const js = await handle.fetch(new Request("http://localhost/client.js"));
|
|
686
|
-
expect(await js.text()).toMatch(/build-/);
|
|
687
|
-
|
|
688
|
-
// Abort watchers before teardown rmSync can fire a restart event.
|
|
689
|
-
await handle.stop();
|
|
690
|
-
handle = undefined;
|
|
691
|
-
} finally {
|
|
692
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
693
|
-
}
|
|
694
|
-
});
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// kumiko-framework#3026: runDevApp gains resolvePageHead parity with
|
|
698
|
-
// runProdApp — both now call the same @cosmicdrift/kumiko-headless/apex
|
|
699
|
-
// resolveAndInjectPageHead, so a resolver produces the same tags in the
|
|
700
|
-
// same order under dev as under prod (see run-prod-app-static-files.test.ts's
|
|
701
|
-
// "resolver meta → og-tags appear" case for the prod-side equivalent). The
|
|
702
|
-
// real 300ms-timeout path is covered once, in headless's own unit test for
|
|
703
|
-
// resolveAndInjectPageHead (same shared function) — no need to re-run a slow
|
|
704
|
-
// timeout wait here too.
|
|
705
|
-
describe("createKumikoServer resolvePageHead", () => {
|
|
706
|
-
test("resolver meta → head tags injected, same tags + order as runProdApp", async () => {
|
|
707
|
-
handle = await createKumikoServer({
|
|
708
|
-
features: [probeFeature],
|
|
709
|
-
port: 0,
|
|
710
|
-
installSignalHandlers: false,
|
|
711
|
-
resolvePageHead: async () => ({
|
|
712
|
-
title: "Vehicle X",
|
|
713
|
-
description: "A car",
|
|
714
|
-
ogImage: "https://x/i.png",
|
|
715
|
-
}),
|
|
716
|
-
});
|
|
717
|
-
const res = await handle.fetch(new Request("http://localhost/"));
|
|
718
|
-
expect(res.status).toBe(200);
|
|
719
|
-
const body = await res.text();
|
|
720
|
-
expect(body).not.toContain("<title>Kumiko</title>");
|
|
721
|
-
expect((body.match(/kumiko-page-head/g) ?? []).length).toBe(1);
|
|
722
|
-
expect(body).toContain("<title>Vehicle X</title>");
|
|
723
|
-
expect(body).toContain('<meta name="description" content="A car" />');
|
|
724
|
-
expect(body).toContain('<meta property="og:title" content="Vehicle X" />');
|
|
725
|
-
expect(body).toContain('<meta property="og:description" content="A car" />');
|
|
726
|
-
expect(body).toContain('<meta property="og:image" content="https://x/i.png" />');
|
|
727
|
-
|
|
728
|
-
const titleIdx = body.indexOf("<title>Vehicle X</title>");
|
|
729
|
-
const descIdx = body.indexOf('<meta name="description"');
|
|
730
|
-
const ogTitleIdx = body.indexOf('<meta property="og:title"');
|
|
731
|
-
const ogDescIdx = body.indexOf('<meta property="og:description"');
|
|
732
|
-
const ogImageIdx = body.indexOf('<meta property="og:image"');
|
|
733
|
-
expect(titleIdx).toBeLessThan(descIdx);
|
|
734
|
-
expect(descIdx).toBeLessThan(ogTitleIdx);
|
|
735
|
-
expect(ogTitleIdx).toBeLessThan(ogDescIdx);
|
|
736
|
-
expect(ogDescIdx).toBeLessThan(ogImageIdx);
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
test("resolver throws → 200 with the unchanged default shell, not a 500", async () => {
|
|
740
|
-
handle = await createKumikoServer({
|
|
741
|
-
features: [probeFeature],
|
|
742
|
-
port: 0,
|
|
743
|
-
installSignalHandlers: false,
|
|
744
|
-
resolvePageHead: async () => {
|
|
745
|
-
throw new Error("boom");
|
|
746
|
-
},
|
|
747
|
-
});
|
|
748
|
-
const res = await handle.fetch(new Request("http://localhost/"));
|
|
749
|
-
expect(res.status).toBe(200);
|
|
750
|
-
const body = await res.text();
|
|
751
|
-
expect(body).not.toContain("kumiko-page-head");
|
|
752
|
-
expect(body).toMatch(/<div id="root">/);
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
test("resolver returns null → 200 with the unchanged default shell", async () => {
|
|
756
|
-
handle = await createKumikoServer({
|
|
757
|
-
features: [probeFeature],
|
|
758
|
-
port: 0,
|
|
759
|
-
installSignalHandlers: false,
|
|
760
|
-
resolvePageHead: async () => null,
|
|
761
|
-
});
|
|
762
|
-
const res = await handle.fetch(new Request("http://localhost/"));
|
|
763
|
-
expect(res.status).toBe(200);
|
|
764
|
-
expect(await res.text()).not.toContain("kumiko-page-head");
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
test("no resolvePageHead configured → no page-head marker in the response", async () => {
|
|
768
|
-
const h = await boot();
|
|
769
|
-
const res = await h.fetch(new Request("http://localhost/"));
|
|
770
|
-
expect(await res.text()).not.toContain("kumiko-page-head");
|
|
771
|
-
});
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
// kumiko-framework#2435: tryHonoFirst used to decide "no route matched"
|
|
775
|
-
// purely from the status code — a matched httpRoute answering 404 on
|
|
776
|
-
// purpose (e.g. default-deny reads) was indistinguishable from an
|
|
777
|
-
// unregistered path and got silently rewritten to the SPA shell with
|
|
778
|
-
// status 200. Drives the REAL fetch-handler end to end (real Postgres,
|
|
779
|
-
// real Hono app via createKumikoServer) so the fix's actual wiring
|
|
780
|
-
// (buildServer's app.notFound() marker + tryHonoFirst reading it) is
|
|
781
|
-
// under test, not just the pure-function pin in try-hono-first.test.ts.
|
|
782
|
-
const probeHttpRouteFeature = defineFeature("dev-server-probe-http-route", (r) => {
|
|
783
|
-
r.httpRoute({
|
|
784
|
-
method: "GET",
|
|
785
|
-
path: "/probe/:id",
|
|
786
|
-
anonymous: true,
|
|
787
|
-
handler: async (c) => {
|
|
788
|
-
const id = c.req.param("id");
|
|
789
|
-
if (id === "missing") return c.text("not found", 404);
|
|
790
|
-
return c.text(`probe:${id}`, 200);
|
|
791
|
-
},
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
describe("createKumikoServer — tryHonoFirst 404-vs-router-miss (#2435)", () => {
|
|
796
|
-
async function bootWithProbeRoute(): Promise<KumikoServerHandle> {
|
|
797
|
-
handle = await createKumikoServer({
|
|
798
|
-
features: [probeHttpRouteFeature],
|
|
799
|
-
port: 0,
|
|
800
|
-
installSignalHandlers: false,
|
|
801
|
-
});
|
|
802
|
-
return handle;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
test("gematchte httpRoute mit bewusster 404 bleibt 404 (keine SPA-Maskierung)", async () => {
|
|
806
|
-
const h = await bootWithProbeRoute();
|
|
807
|
-
const res = await h.fetch(new Request("http://localhost/probe/missing"));
|
|
808
|
-
expect(res.status).toBe(404);
|
|
809
|
-
expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
|
|
810
|
-
expect(await res.text()).toBe("not found");
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test("gematchte httpRoute mit Treffer antwortet normal (200)", async () => {
|
|
814
|
-
const h = await bootWithProbeRoute();
|
|
815
|
-
const res = await h.fetch(new Request("http://localhost/probe/exists"));
|
|
816
|
-
expect(res.status).toBe(200);
|
|
817
|
-
expect(await res.text()).toBe("probe:exists");
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
test("unbekannte SPA-Route liefert weiterhin die SPA-Shell (200 HTML)", async () => {
|
|
821
|
-
const h = await bootWithProbeRoute();
|
|
822
|
-
const res = await h.fetch(new Request("http://localhost/some/client-side/route"));
|
|
823
|
-
expect(res.status).toBe(200);
|
|
824
|
-
expect(res.headers.get("content-type")).toMatch(/text\/html/);
|
|
825
|
-
expect(await res.text()).toMatch(/<div id="root">/);
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
test("kein Router-miss-Marker leakt an den Client", async () => {
|
|
829
|
-
const h = await bootWithProbeRoute();
|
|
830
|
-
const missRes = await h.fetch(new Request("http://localhost/some/client-side/route"));
|
|
831
|
-
const deniedRes = await h.fetch(new Request("http://localhost/probe/missing"));
|
|
832
|
-
expect(missRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
833
|
-
expect(deniedRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
834
|
-
});
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
// A dotted GET path (e.g. /marketing/hero.png) used to fall through the SPA
|
|
838
|
-
// catch-all's "no dot" filter straight to the API stack and 404 — works in
|
|
839
|
-
// prod (buildStaticFallback's disk lookup under staticDir) but not dev,
|
|
840
|
-
// which had no public/-serving at all. publicDir is process.cwd()-relative
|
|
841
|
-
// (same App-Root convention as resolveStylesheet's src/styles.css lookup),
|
|
842
|
-
// so these tests chdir into a fixture directory for the boot.
|
|
843
|
-
describe("createKumikoServer — public/ static files", () => {
|
|
844
|
-
test("GET on an existing file under public/ → 200, correct content-type + content", async () => {
|
|
845
|
-
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-")));
|
|
846
|
-
const publicDir = join(tmpDir, "public");
|
|
847
|
-
mkdirSync(join(publicDir, "marketing"), { recursive: true });
|
|
848
|
-
writeFileSync(join(publicDir, "marketing", "hero.png"), "PNGDATA");
|
|
849
|
-
const cwdBefore = process.cwd();
|
|
850
|
-
process.chdir(tmpDir);
|
|
851
|
-
try {
|
|
852
|
-
handle = await createKumikoServer({
|
|
853
|
-
features: [probeFeature],
|
|
854
|
-
port: 0,
|
|
855
|
-
installSignalHandlers: false,
|
|
856
|
-
});
|
|
857
|
-
const res = await handle.fetch(new Request("http://localhost/marketing/hero.png"));
|
|
858
|
-
expect(res.status).toBe(200);
|
|
859
|
-
expect(res.headers.get("content-type")).toBe("image/png");
|
|
860
|
-
expect(await res.text()).toBe("PNGDATA");
|
|
861
|
-
} finally {
|
|
862
|
-
process.chdir(cwdBefore);
|
|
863
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
test("traversal attempts never serve a file from outside public/", async () => {
|
|
868
|
-
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-traversal-")));
|
|
869
|
-
const publicDir = join(tmpDir, "public");
|
|
870
|
-
mkdirSync(publicDir, { recursive: true });
|
|
871
|
-
// Secret sits as a SIBLING of public/ — a traversal that escapes
|
|
872
|
-
// containment would read this instead of 404ing.
|
|
873
|
-
writeFileSync(join(tmpDir, "secret.txt"), "TOP-SECRET");
|
|
874
|
-
const cwdBefore = process.cwd();
|
|
875
|
-
process.chdir(tmpDir);
|
|
876
|
-
try {
|
|
877
|
-
handle = await createKumikoServer({
|
|
878
|
-
features: [probeFeature],
|
|
879
|
-
port: 0,
|
|
880
|
-
installSignalHandlers: false,
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
// WHATWG URL parsing already collapses a literal ".." segment (it's
|
|
884
|
-
// delimited by a real "/"), so this pins the composed behavior —
|
|
885
|
-
// request never reaches secret.txt — rather than the guard itself.
|
|
886
|
-
const literal = await handle.fetch(new Request("http://localhost/../secret.txt"));
|
|
887
|
-
expect(literal.status).not.toBe(200);
|
|
888
|
-
|
|
889
|
-
// %2e%2e%2f is the actual vector the decode+resolve+containment guard
|
|
890
|
-
// exists for: URL path-parsing only normalizes dot-segments split by a
|
|
891
|
-
// literal "/", so an encoded slash survives untouched into pathname —
|
|
892
|
-
// without the explicit decode in resolvePublicFilePath this would
|
|
893
|
-
// resolve straight to tmpDir/secret.txt.
|
|
894
|
-
const encoded = await handle.fetch(new Request("http://localhost/%2e%2e%2fsecret.txt"));
|
|
895
|
-
expect(encoded.status).not.toBe(200);
|
|
896
|
-
|
|
897
|
-
// Neither attempt leaked the secret's content through any other path
|
|
898
|
-
// (e.g. as an error body).
|
|
899
|
-
expect(await literal.clone().text()).not.toContain("TOP-SECRET");
|
|
900
|
-
expect(await encoded.clone().text()).not.toContain("TOP-SECRET");
|
|
901
|
-
} finally {
|
|
902
|
-
process.chdir(cwdBefore);
|
|
903
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
test("a dot-less path still hits the SPA catch-all, not the public/-file lookup", async () => {
|
|
908
|
-
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-spa-")));
|
|
909
|
-
const publicDir = join(tmpDir, "public");
|
|
910
|
-
mkdirSync(publicDir, { recursive: true });
|
|
911
|
-
const cwdBefore = process.cwd();
|
|
912
|
-
process.chdir(tmpDir);
|
|
913
|
-
try {
|
|
914
|
-
handle = await createKumikoServer({
|
|
915
|
-
features: [probeFeature],
|
|
916
|
-
port: 0,
|
|
917
|
-
installSignalHandlers: false,
|
|
918
|
-
});
|
|
919
|
-
const res = await handle.fetch(new Request("http://localhost/some/client-route"));
|
|
920
|
-
expect(res.status).toBe(200);
|
|
921
|
-
expect(res.headers.get("content-type")).toMatch(/text\/html/);
|
|
922
|
-
expect(await res.text()).toMatch(/<div id="root">/);
|
|
923
|
-
} finally {
|
|
924
|
-
process.chdir(cwdBefore);
|
|
925
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
926
|
-
}
|
|
927
|
-
});
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
describe("createKumikoServer — renderer-web fonts under /assets/kumiko/fonts", () => {
|
|
931
|
-
const FONTS_SOURCE_DIR = join(import.meta.dir, "../../../renderer-web/src/fonts");
|
|
932
|
-
|
|
933
|
-
async function withFixtureCwd(run: () => Promise<void>): Promise<void> {
|
|
934
|
-
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-fonts-")));
|
|
935
|
-
const cwdBefore = process.cwd();
|
|
936
|
-
process.chdir(tmpDir);
|
|
937
|
-
try {
|
|
938
|
-
handle = await createKumikoServer({
|
|
939
|
-
features: [probeFeature],
|
|
940
|
-
port: 0,
|
|
941
|
-
installSignalHandlers: false,
|
|
942
|
-
});
|
|
943
|
-
await run();
|
|
944
|
-
} finally {
|
|
945
|
-
process.chdir(cwdBefore);
|
|
946
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
test("GET on a shipped font → 200, font/woff2, the file's bytes", async () => {
|
|
951
|
-
await withFixtureCwd(async () => {
|
|
952
|
-
const res = await handle?.fetch(
|
|
953
|
-
new Request("http://localhost/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2"),
|
|
954
|
-
);
|
|
955
|
-
expect(res?.status).toBe(200);
|
|
956
|
-
expect(res?.headers.get("content-type")).toBe("font/woff2");
|
|
957
|
-
const expected = readFileSync(join(FONTS_SOURCE_DIR, "ibm-plex-sans-latin-400.woff2"));
|
|
958
|
-
expect(Buffer.from(await (res as Response).arrayBuffer()).equals(expected)).toBe(true);
|
|
959
|
-
});
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
test("unknown names and traversal attempts → 404, never file content", async () => {
|
|
963
|
-
await withFixtureCwd(async () => {
|
|
964
|
-
for (const path of [
|
|
965
|
-
"/assets/kumiko/fonts/nope.woff2",
|
|
966
|
-
"/assets/kumiko/fonts/OFL.txt",
|
|
967
|
-
"/assets/kumiko/fonts/../styles.css",
|
|
968
|
-
"/assets/kumiko/fonts/..%2fstyles.css",
|
|
969
|
-
"/assets/kumiko/fonts/%2e%2e%2f%2e%2e%2fpackage.json",
|
|
970
|
-
"/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2/x",
|
|
971
|
-
]) {
|
|
972
|
-
const res = await handle?.fetch(new Request(`http://localhost${path}`));
|
|
973
|
-
expect(res?.status, path).toBe(404);
|
|
974
|
-
}
|
|
975
|
-
});
|
|
976
|
-
});
|
|
977
|
-
});
|
|
978
|
-
|
|
979
|
-
describe("createKumikoServer — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
|
|
980
|
-
// Every other test in this file drives h.fetch()/handle.fetch() directly,
|
|
981
|
-
// bypassing Bun.serve's real TCP socket entirely — none of them can catch
|
|
982
|
-
// a regression where handleFetch loses the socket address before it
|
|
983
|
-
// reaches requestContext.ip (see client-ip.ts: falls back to "unknown"
|
|
984
|
-
// rather than throwing, so a wiring bug here stays silent otherwise).
|
|
985
|
-
test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
|
|
986
|
-
handle = await createKumikoServer({
|
|
987
|
-
features: [probeFeature],
|
|
988
|
-
port: 0,
|
|
989
|
-
installSignalHandlers: false,
|
|
990
|
-
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
991
|
-
});
|
|
992
|
-
const port = handle.server?.port;
|
|
993
|
-
if (port === undefined) throw new Error("expected handle.server to be listening");
|
|
994
|
-
|
|
995
|
-
const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
|
|
996
|
-
method: "POST",
|
|
997
|
-
headers: { "content-type": "application/json" },
|
|
998
|
-
body: JSON.stringify({ type: "dev-server-probe:query:whoami", payload: {} }),
|
|
999
|
-
});
|
|
1000
|
-
expect(res.status).toBe(200);
|
|
1001
|
-
const body = (await res.json()) as { data?: { ip?: string } };
|
|
1002
|
-
// See the matching prod-side test (run-prod-app.integration.test.ts)
|
|
1003
|
-
// for why the assertion matches the loopback family instead of a
|
|
1004
|
-
// fixed literal (IPv4 vs. IPv4-mapped IPv6 is a platform detail).
|
|
1005
|
-
expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
|
1006
|
-
});
|
|
1007
|
-
});
|