@nanobpm/urban 0.48.0 → 0.49.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 +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +34 -2
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +54 -6
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +43 -0
- package/src/runtime/core/modules/pages.ts +34 -2
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +46 -10
- package/src/toolkit/gen.ts +59 -6
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
|
40
40
|
calls.messages.push(input);
|
|
41
41
|
},
|
|
42
42
|
async searchUserTasks() { return []; },
|
|
43
|
+
async getForm() { return null; },
|
|
43
44
|
async completeUserTask() {},
|
|
44
45
|
async searchProcessInstances() { return []; },
|
|
45
46
|
async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
|
|
@@ -601,6 +602,48 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
|
|
|
601
602
|
assert.match(js, /container\.append\(header, body\);\s*return container;/);
|
|
602
603
|
});
|
|
603
604
|
|
|
605
|
+
test("renderer surfaces a live row-count badge on a dataGrid's collapsible header (showCount)", async () => {
|
|
606
|
+
// A dataGrid opting in via `showCount:true` renders a count pill that reflects the
|
|
607
|
+
// currently-fetched, filtered (active-tab) row set, updates on every refresh (the
|
|
608
|
+
// refreshMs poll and pc:refresh), and — because makeCollapsible re-homes it into the
|
|
609
|
+
// collapse header — stays visible while the section is collapsed. Omitting showCount
|
|
610
|
+
// is a no-op (backward-compatible).
|
|
611
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
612
|
+
const js = res.body ?? "";
|
|
613
|
+
// Opt-in badge, created only when showCount is set; class-tagged for re-homing + CSS.
|
|
614
|
+
assert.match(js, /const countBadge = p\.showCount \? el\("span", \{ class: "pc-badge pc-count-badge"/);
|
|
615
|
+
// The badge's accessible name carries the count (kept in sync on refresh) rather than a
|
|
616
|
+
// bare aria-label that would override the number screen readers need to announce.
|
|
617
|
+
assert.match(js, /"aria-label": rowCountLabel\(0\)/);
|
|
618
|
+
assert.match(js, /function rowCountLabel\(n\) \{ return n \+ \(n === 1 \? " row" : " rows"\); \}/);
|
|
619
|
+
// The <h2> (title and/or badge) renders when either the title or the badge is present.
|
|
620
|
+
assert.match(js, /if \(p\.title \|\| countBadge\) \{/);
|
|
621
|
+
// The badge is appended to the <h2>; the " " spacer is inserted only when a title is
|
|
622
|
+
// present, so a badge-only header can't render (or store) a stray leading space.
|
|
623
|
+
assert.match(js, /if \(countBadge\) \{ if \(p\.title\) h2\.append\(" "\); h2\.append\(countBadge\); \}/);
|
|
624
|
+
// Every refresh rewrites the count from the fetched, filtered row set (poll + pc:refresh),
|
|
625
|
+
// keeping both the visible text and the accessible label in sync.
|
|
626
|
+
assert.match(js, /if \(countBadge\) \{ countBadge\.textContent = String\(rows\.length\); countBadge\.setAttribute\("aria-label", rowCountLabel\(rows\.length\)\); \}/);
|
|
627
|
+
// makeCollapsible lifts the badge out of the <h2> before deriving the title (so the
|
|
628
|
+
// count can't leak into the label) and re-homes it in the header so it stays visible
|
|
629
|
+
// while collapsed (only the body is hidden).
|
|
630
|
+
assert.match(js, /const countBadge = h2 && h2\.querySelector \? h2\.querySelector\("\.pc-count-badge"\) : null;/);
|
|
631
|
+
assert.match(js, /if \(countBadge\) countBadge\.remove\(\);/);
|
|
632
|
+
assert.match(js, /if \(countBadge\) header\.append\(countBadge\);/);
|
|
633
|
+
// The derived collapse-header title is trimmed so a badge-only <h2> (no title) can't
|
|
634
|
+
// leave a lone whitespace node as the label (or leak whitespace into the storage key).
|
|
635
|
+
assert.match(js, /const titleText = props\.title \|\| \(h2 \? h2\.textContent\.trim\(\) : ""\) \|\| "Section";/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("the shell styles the row-count pill as a neutral count distinct from status badges", async () => {
|
|
639
|
+
const res = await dispatch("GET", "/");
|
|
640
|
+
const html = res.body ?? "";
|
|
641
|
+
const rule = (html.match(/\.pc-count-badge\s*\{[^}]*\}/) ?? [""])[0];
|
|
642
|
+
assert.ok(rule, ".pc-count-badge rule must be present");
|
|
643
|
+
assert.match(rule, /background:var\(--nano-inset\)/);
|
|
644
|
+
assert.match(rule, /color:var\(--nano-text\)/);
|
|
645
|
+
});
|
|
646
|
+
|
|
604
647
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
605
648
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
606
649
|
assert.equal(ok.status, 200);
|
|
@@ -564,6 +564,9 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
564
564
|
.pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
|
|
565
565
|
.pc-badge-ok { background:var(--nano-ok); }
|
|
566
566
|
.pc-badge-info { background:var(--nano-info); }
|
|
567
|
+
/* Live row-count pill (dataGrid showCount): a subtle, neutral count distinct from
|
|
568
|
+
the coloured status badges, legible in the collapse header while collapsed. */
|
|
569
|
+
.pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
|
|
567
570
|
:root[data-appearance="light"] .pc-badge-warn { color:#fff; }
|
|
568
571
|
@media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
|
|
569
572
|
.pc-child { margin:.6rem 0; }
|
|
@@ -1266,10 +1269,25 @@ function renderActionForm(node) {
|
|
|
1266
1269
|
return card;
|
|
1267
1270
|
}
|
|
1268
1271
|
|
|
1272
|
+
// Accessible name for the row-count badge. The visible text is just a number, so an
|
|
1273
|
+
// aria-label would override it with a bare "row count" (the count — the important part —
|
|
1274
|
+
// is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
|
|
1275
|
+
// Single source of truth for both the initial render and every refresh below.
|
|
1276
|
+
function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
|
|
1277
|
+
|
|
1269
1278
|
function renderDataGrid(node) {
|
|
1270
1279
|
const p = node.props;
|
|
1271
1280
|
const card = el("section", { class: "pc-card" });
|
|
1272
|
-
|
|
1281
|
+
// Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
|
|
1282
|
+
// (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
|
|
1283
|
+
// into the collapse header so it stays visible — and refresh-updated — while the
|
|
1284
|
+
// section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
|
|
1285
|
+
const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
|
|
1286
|
+
if (p.title || countBadge) {
|
|
1287
|
+
const h2 = el("h2", {}, p.title || "");
|
|
1288
|
+
if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
|
|
1289
|
+
card.append(h2);
|
|
1290
|
+
}
|
|
1273
1291
|
const cols = p.columns || [];
|
|
1274
1292
|
const tabs = p.tabs || [];
|
|
1275
1293
|
const rowActions = p.rowActions || [];
|
|
@@ -1587,6 +1605,10 @@ function renderDataGrid(node) {
|
|
|
1587
1605
|
const { rows } = paramScoped && PARAM === ""
|
|
1588
1606
|
? { rows: [] }
|
|
1589
1607
|
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1608
|
+
// Live row-count badge: reflect the currently-fetched, filtered (active-tab)
|
|
1609
|
+
// set — exactly what the body renders — on every refresh (poll + pc:refresh),
|
|
1610
|
+
// whether the section is expanded or collapsed.
|
|
1611
|
+
if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
|
|
1590
1612
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1591
1613
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1592
1614
|
if (p.rowKey) {
|
|
@@ -1755,7 +1777,16 @@ function makeCollapsible(node, card) {
|
|
|
1755
1777
|
const isCard = card.tagName === "SECTION";
|
|
1756
1778
|
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
1757
1779
|
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
1758
|
-
|
|
1780
|
+
// A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
|
|
1781
|
+
// out before deriving the title (so the count doesn't leak into the label text),
|
|
1782
|
+
// then re-home it in the header alongside the title so it stays visible — and
|
|
1783
|
+
// refresh-updated by the grid's own refresh() — while the section is collapsed.
|
|
1784
|
+
const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
|
|
1785
|
+
if (countBadge) countBadge.remove();
|
|
1786
|
+
// Trim the derived label: after the badge is lifted out, the leftover " " whitespace
|
|
1787
|
+
// node from h2.append(" ", countBadge) would otherwise make a title-less section's
|
|
1788
|
+
// header a lone space (and leak whitespace into the storage key). Fall back to "Section".
|
|
1789
|
+
const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
|
|
1759
1790
|
if (h2) h2.remove();
|
|
1760
1791
|
const body = el("div", { class: "pc-card-body" });
|
|
1761
1792
|
if (isCard) {
|
|
@@ -1770,6 +1801,7 @@ function makeCollapsible(node, card) {
|
|
|
1770
1801
|
chevron,
|
|
1771
1802
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
1772
1803
|
);
|
|
1804
|
+
if (countBadge) header.append(countBadge);
|
|
1773
1805
|
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
1774
1806
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
1775
1807
|
function apply() {
|
|
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
|
|
|
29
29
|
cancelInstance: async () => {},
|
|
30
30
|
publishMessage: async () => {},
|
|
31
31
|
searchUserTasks: async () => [],
|
|
32
|
+
getForm: async () => null,
|
|
32
33
|
completeUserTask: async () => {},
|
|
33
34
|
searchProcessInstances: async () => [],
|
|
34
35
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -43,6 +44,7 @@ const fakeEngine: EngineClient = {
|
|
|
43
44
|
cancelInstance: async () => {},
|
|
44
45
|
publishMessage: async () => {},
|
|
45
46
|
searchUserTasks: async () => [],
|
|
47
|
+
getForm: async () => null,
|
|
46
48
|
completeUserTask: async () => {},
|
|
47
49
|
searchProcessInstances: async () => [],
|
|
48
50
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -76,13 +78,204 @@ test("chat agent is HTML-escaped in the mount page", async () => {
|
|
|
76
78
|
assert.ok(body.includes("<img src=x onerror=alert(1)>"), "agent is escaped");
|
|
77
79
|
});
|
|
78
80
|
|
|
79
|
-
test("task-inbox
|
|
81
|
+
test("task-inbox client derives its API base from location (reverse-proxy safe, no path embedding)", async () => {
|
|
82
|
+
// The client must NOT embed the manifest-supplied route base as an absolute
|
|
83
|
+
// literal: a hardcoded "/tasks" escapes the Nano console's path-prefixed
|
|
84
|
+
// reverse proxy (/console/app-view/<name>/tasks) — every fetch to
|
|
85
|
+
// "/tasks/api/…" 404s upstream. Deriving the base from location.pathname keeps
|
|
86
|
+
// the API calls document-relative (correct at the origin root AND under the
|
|
87
|
+
// proxy) and, as a bonus, removes the manifest→<script> injection surface
|
|
88
|
+
// entirely: nothing manifest-supplied is embedded in the page at all.
|
|
80
89
|
const evil = "/tasks'});alert(1);//x";
|
|
81
90
|
const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: evil } }), fakeApp);
|
|
82
91
|
const page = s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && !r.path.endsWith("/api/tasks"))!;
|
|
83
92
|
const body = await render(page);
|
|
84
|
-
// The path
|
|
85
|
-
|
|
86
|
-
assert.ok(!body.includes(
|
|
87
|
-
|
|
93
|
+
// The manifest path (evil or otherwise) is never emitted into the page.
|
|
94
|
+
assert.ok(!body.includes("alert(1)"), "manifest path is not embedded — nothing to inject");
|
|
95
|
+
assert.ok(!body.includes(JSON.stringify(evil)), "manifest path is not embedded as a JS literal");
|
|
96
|
+
// The base is derived from where the page was actually served, so it inherits
|
|
97
|
+
// any reverse-proxy path prefix instead of a root-absolute mount path.
|
|
98
|
+
assert.ok(body.includes("location.pathname"), "client derives its API base from location.pathname");
|
|
99
|
+
assert.ok(!/const\s+BASE\s*=\s*["'/]/.test(body), "client does not embed an absolute route base");
|
|
88
100
|
});
|
|
101
|
+
|
|
102
|
+
/** Build an app handle backed by a specific engine (the flow tests drive the routes). */
|
|
103
|
+
function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
|
|
104
|
+
return {
|
|
105
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
106
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
107
|
+
engine,
|
|
108
|
+
env: () => undefined,
|
|
109
|
+
log: createLogger(() => {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Invoke a route handler with an optional query string and POST body. */
|
|
114
|
+
async function call(
|
|
115
|
+
route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> },
|
|
116
|
+
opts: { method?: string; query?: string; body?: string } = {},
|
|
117
|
+
): Promise<HttpResponse> {
|
|
118
|
+
return route.handler({
|
|
119
|
+
method: opts.method ?? "GET",
|
|
120
|
+
path: "/",
|
|
121
|
+
query: new URLSearchParams(opts.query ?? ""),
|
|
122
|
+
headers: new Headers(),
|
|
123
|
+
text: async () => opts.body ?? "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inboxRoutes(engine: EngineClient) {
|
|
128
|
+
const s = mountSurfaces(ctxWith({ taskInbox: { enabled: true, path: "/tasks" } }), appWith(engine));
|
|
129
|
+
const at = (suffix: string, method = "GET") =>
|
|
130
|
+
s.routes.find(
|
|
131
|
+
(r) => r.source === "surface:taskInbox" && r.method === method && r.path.endsWith(suffix),
|
|
132
|
+
)!;
|
|
133
|
+
return {
|
|
134
|
+
page: s.routes.find((r) => r.source === "surface:taskInbox" && r.method === "GET" && r.path === "/tasks")!,
|
|
135
|
+
tasks: at("/api/tasks"),
|
|
136
|
+
form: at("/api/form"),
|
|
137
|
+
complete: at("/api/complete", "POST"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("inbox page renders the client-side form fetch + renderer", async () => {
|
|
142
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
143
|
+
const rendered = String((await call(page)).body);
|
|
144
|
+
assert.ok(rendered.includes("/api/form"), "page fetches the linked form");
|
|
145
|
+
assert.ok(rendered.includes("function renderForm"), "page has a form renderer");
|
|
146
|
+
assert.ok(rendered.includes("function buildField"), "page builds fields from the schema");
|
|
147
|
+
assert.ok(rendered.includes("/api/complete"), "page posts completion");
|
|
148
|
+
// Drift guard: the client script is a stringified <script> body, so it is invisible to
|
|
149
|
+
// the type checker. UserTaskSummary carries `formKey`/`externalFormReference` but no
|
|
150
|
+
// `formId`, so the client must never branch on a task-supplied `formId` (that phantom
|
|
151
|
+
// field would silently be `undefined`). Lock the drift closed.
|
|
152
|
+
assert.ok(!rendered.includes("formId"), "client script must not reference formId — UserTaskSummary carries none");
|
|
153
|
+
// Prototype-pollution guard: submit builds `variables` from engine-supplied component
|
|
154
|
+
// keys (a schema could key a field '__proto__'/'constructor'). It must use a null-
|
|
155
|
+
// prototype bag so an untrusted key lands as an own property, never mutating a prototype.
|
|
156
|
+
assert.ok(rendered.includes("Object.create(null)"), "client builds the variables bag with a null prototype");
|
|
157
|
+
assert.ok(!/const\s+variables\s*=\s*\{\}/.test(rendered), "client must not collect untrusted keys into a plain {} object");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("/api/tasks surfaces the resolved form linkage", async () => {
|
|
161
|
+
let seenFilter: unknown;
|
|
162
|
+
const engine: EngineClient = {
|
|
163
|
+
...fakeEngine,
|
|
164
|
+
searchUserTasks: async (filter) => {
|
|
165
|
+
seenFilter = filter;
|
|
166
|
+
return [
|
|
167
|
+
{ userTaskKey: "1", elementId: "approve", formKey: "form-123" },
|
|
168
|
+
{ userTaskKey: "2", elementId: "review" },
|
|
169
|
+
];
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
const { tasks } = inboxRoutes(engine);
|
|
173
|
+
const res = await call(tasks, { query: "processInstanceKey=pi-9" });
|
|
174
|
+
assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
|
|
175
|
+
const parsed = JSON.parse(String(res.body));
|
|
176
|
+
assert.equal(parsed[0].formKey, "form-123");
|
|
177
|
+
assert.equal(parsed[1].formKey, undefined);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("/api/form resolves a linked form's schema", async () => {
|
|
181
|
+
let seen: unknown;
|
|
182
|
+
const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
|
|
183
|
+
const engine: EngineClient = {
|
|
184
|
+
...fakeEngine,
|
|
185
|
+
getForm: async (input) => {
|
|
186
|
+
seen = input;
|
|
187
|
+
return { formKey: "form-123", schema };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const { form } = inboxRoutes(engine);
|
|
191
|
+
const res = await call(form, { query: "formKey=form-123" });
|
|
192
|
+
assert.deepEqual(seen, { formKey: "form-123", formId: undefined });
|
|
193
|
+
assert.equal(res.status, 200);
|
|
194
|
+
assert.deepEqual(JSON.parse(String(res.body)).schema, schema);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("/api/form requires an identifier", async () => {
|
|
198
|
+
const { form } = inboxRoutes(fakeEngine);
|
|
199
|
+
const res = await call(form);
|
|
200
|
+
assert.equal(res.status, 400);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("/api/form passes an empty formKey through so getForm can fall back to formId", async () => {
|
|
204
|
+
// A `?formKey=&formId=…` request must not be rejected at the boundary (only *both*
|
|
205
|
+
// identifiers absent is a 400) nor stripped here — resolving an empty key to its formId
|
|
206
|
+
// fallback is getForm's single responsibility (see nanosdk getForm empty/whitespace test).
|
|
207
|
+
let seen: unknown;
|
|
208
|
+
const engine: EngineClient = {
|
|
209
|
+
...fakeEngine,
|
|
210
|
+
getForm: async (input) => {
|
|
211
|
+
seen = input;
|
|
212
|
+
return { formKey: "form-9", schema: { type: "default", components: [] } };
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const { form } = inboxRoutes(engine);
|
|
216
|
+
const res = await call(form, { query: "formKey=&formId=form-9" });
|
|
217
|
+
assert.equal(res.status, 200);
|
|
218
|
+
assert.deepEqual(seen, { formKey: "", formId: "form-9" });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("/api/form 400s a whitespace-only identifier instead of a spurious 204", async () => {
|
|
222
|
+
// A `?formKey= ` (or `formId= `) request provides no usable identifier. The route's
|
|
223
|
+
// presence gate must follow getForm's canonical rule (whitespace = absent) and 400,
|
|
224
|
+
// rather than letting the blank key slip past a raw truthiness check to getForm (→ null
|
|
225
|
+
// → 204). Guards the route↔getForm presence drift on the taskInbox form path — Red when
|
|
226
|
+
// the gate checks raw `!formKey && !formId`, since " " is truthy.
|
|
227
|
+
const { form } = inboxRoutes(fakeEngine);
|
|
228
|
+
assert.equal((await call(form, { query: "formKey=%20%20%20" })).status, 400);
|
|
229
|
+
assert.equal((await call(form, { query: "formId=%20%20" })).status, 400);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("/api/form returns 204 for a task with no resolvable form (no-form fallback)", async () => {
|
|
233
|
+
const engine: EngineClient = { ...fakeEngine, getForm: async () => null };
|
|
234
|
+
const { form } = inboxRoutes(engine);
|
|
235
|
+
const res = await call(form, { query: "formKey=missing" });
|
|
236
|
+
assert.equal(res.status, 204);
|
|
237
|
+
// The body must be empty: a "null" payload (json(null, 204)) makes the client's
|
|
238
|
+
// fetch helper throw on parse and surface "Failed to load form" instead of the
|
|
239
|
+
// no-form fallback. Guard the wire shape here and the client short-circuit below.
|
|
240
|
+
assert.equal(res.body, "");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("inbox client fetch helper short-circuits 204 so the no-form fallback renders", async () => {
|
|
244
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
245
|
+
const rendered = String((await call(page)).body);
|
|
246
|
+
// The api() helper must not call r.json() on a 204 (empty body → throw). It has
|
|
247
|
+
// to resolve to null so openForm() falls through to renderNoForm(t).
|
|
248
|
+
assert.ok(rendered.includes("r.status===204"), "api() short-circuits a 204 response");
|
|
249
|
+
assert.ok(rendered.includes("function renderNoForm"), "page has a no-form renderer");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("number field reader guards against a non-numeric (tampered) value", async () => {
|
|
253
|
+
// A number field submits `Number(raw)`; a non-numeric value (only reachable via tampering
|
|
254
|
+
// — the browser blanks invalid type=number input) yields NaN, which JSON.stringify
|
|
255
|
+
// serializes as null, silently changing the submission. The inline reader must guard it
|
|
256
|
+
// with Number.isFinite so a bad value is treated as absent rather than submitted as NaN.
|
|
257
|
+
const { page } = inboxRoutes(fakeEngine);
|
|
258
|
+
const rendered = String((await call(page)).body);
|
|
259
|
+
assert.ok(rendered.includes("Number.isFinite"), "number field guards NaN via Number.isFinite");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("/api/complete completes the task with the submitted variables", async () => {
|
|
263
|
+
const calls: { key: string; variables?: Record<string, unknown> }[] = [];
|
|
264
|
+
const engine: EngineClient = {
|
|
265
|
+
...fakeEngine,
|
|
266
|
+
completeUserTask: async (key, variables) => {
|
|
267
|
+
calls.push({ key, variables });
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const { complete } = inboxRoutes(engine);
|
|
271
|
+
const res = await call(complete, { method: "POST", body: JSON.stringify({ userTaskKey: "7", variables: { name: "Ada", agree: true } }) });
|
|
272
|
+
assert.equal(res.status, 200);
|
|
273
|
+
assert.deepEqual(calls, [{ key: "7", variables: { name: "Ada", agree: true } }]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("/api/complete rejects a body with no userTaskKey", async () => {
|
|
277
|
+
const { complete } = inboxRoutes(fakeEngine);
|
|
278
|
+
const res = await call(complete, { method: "POST", body: JSON.stringify({ variables: {} }) });
|
|
279
|
+
assert.equal(res.status, 400);
|
|
280
|
+
});
|
|
281
|
+
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// surfaces — the hosted human surfaces (ADR 0026). Implements the `taskInbox` surface: a
|
|
2
|
-
// small JSON+HTML task list backed by the engine's user-task search
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
2
|
+
// small JSON+HTML task list backed by the engine's user-task search. Each open task links to
|
|
3
|
+
// its deployed `.form` via the engine-resolved `formKey`; the list fetches that form's form-js
|
|
4
|
+
// schema (`GET base/api/form`), renders its fields client-side with a minimal built-in
|
|
5
|
+
// renderer, and posts the entered field values as the completion `variables` (`POST
|
|
6
|
+
// base/api/complete`). A task with no linked form keeps the bare key-list + raw-complete
|
|
7
|
+
// behavior. The `chat` surface is stubbed as a mount point (LLM wiring is a separate concern).
|
|
8
|
+
// Surfaces contribute routes to the shared server.
|
|
5
9
|
|
|
6
10
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
7
|
-
import {
|
|
11
|
+
import { presentFormIdentifier } from "../host.ts";
|
|
12
|
+
import { html, json, noContent, normalizeRoutePath, type Route } from "../router.ts";
|
|
8
13
|
import { mountActions } from "./actions.ts";
|
|
9
14
|
import { mountApi } from "./api.ts";
|
|
10
15
|
import { mountPages } from "./pages.ts";
|
|
@@ -22,23 +27,169 @@ export interface SurfacesHandle {
|
|
|
22
27
|
describe(): Record<string, unknown>;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
function inboxPage(
|
|
30
|
+
function inboxPage(): string {
|
|
31
|
+
// Reverse-proxy safe: the client derives its API base from location.pathname
|
|
32
|
+
// (where THIS page was actually served) rather than an embedded absolute
|
|
33
|
+
// route base. A hardcoded "/tasks" would escape the Nano console's
|
|
34
|
+
// path-prefixed proxy (/console/app-view/<name>/tasks) — the API fetches must
|
|
35
|
+
// inherit that prefix. Deriving from location also removes the manifest→
|
|
36
|
+
// <script> injection surface entirely: nothing manifest-supplied is embedded.
|
|
37
|
+
// All task/form values are rendered via textContent / setAttribute — never
|
|
38
|
+
// innerHTML — so nothing the engine returns can inject markup.
|
|
26
39
|
return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
|
|
27
40
|
<style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
|
|
28
|
-
li{margin:.4rem 0
|
|
41
|
+
ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
|
|
42
|
+
code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
|
|
43
|
+
button{font:inherit;padding:.2rem .6rem;cursor:pointer}
|
|
44
|
+
form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
|
|
45
|
+
form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
|
|
46
|
+
form .field input[type=checkbox],form .field input[type=radio]{width:auto}
|
|
47
|
+
#form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
|
|
48
|
+
.muted{color:#666}</style>
|
|
29
49
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
50
|
+
<div id="form"></div>
|
|
30
51
|
<script>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
const BASE=location.pathname.replace(/\/+$/,'');
|
|
53
|
+
const tasksEl=document.getElementById('tasks');
|
|
54
|
+
const formEl=document.getElementById('form');
|
|
55
|
+
|
|
56
|
+
function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.text().then(t=>{throw new Error(t||r.status)});if(r.status===204)return null;return r.json();});}
|
|
57
|
+
|
|
58
|
+
function loadTasks(){
|
|
59
|
+
formEl.replaceChildren();
|
|
60
|
+
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
61
|
+
return api('/api/tasks').then(ts=>{
|
|
62
|
+
tasksEl.replaceChildren();
|
|
63
|
+
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
64
|
+
for(const t of ts){
|
|
65
|
+
const li=document.createElement('li');
|
|
66
|
+
const code=el('code',{},t.elementId||t.userTaskKey);
|
|
67
|
+
li.appendChild(code);
|
|
68
|
+
li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
|
|
69
|
+
if(t.formKey){
|
|
70
|
+
li.appendChild(btn('Open',()=>openForm(t)));
|
|
71
|
+
}else{
|
|
72
|
+
li.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
73
|
+
}
|
|
74
|
+
tasksEl.appendChild(li);
|
|
75
|
+
}
|
|
76
|
+
}).catch(err=>tasksEl.replaceChildren(el('li',{class:'muted'},'Failed to load tasks: '+err.message)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openForm(t){
|
|
80
|
+
formEl.replaceChildren(el('p',{class:'muted'},'loading form…'));
|
|
81
|
+
const q='formKey='+encodeURIComponent(t.formKey);
|
|
82
|
+
api('/api/form?'+q).then(f=>{
|
|
83
|
+
formEl.replaceChildren();
|
|
84
|
+
if(!f||!f.schema){renderNoForm(t);return;}
|
|
85
|
+
renderForm(t,f.schema);
|
|
86
|
+
}).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderNoForm(t){
|
|
90
|
+
formEl.appendChild(el('p',{class:'muted'},'This task has no renderable form.'));
|
|
91
|
+
const bar=el('div',{});
|
|
92
|
+
bar.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
93
|
+
bar.appendChild(btn('Cancel',loadTasks));
|
|
94
|
+
formEl.appendChild(bar);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderForm(t,schema){
|
|
98
|
+
const form=document.createElement('form');
|
|
99
|
+
form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
|
|
100
|
+
const components=Array.isArray(schema.components)?schema.components:[];
|
|
101
|
+
const inputs=[];
|
|
102
|
+
for(const c of components){
|
|
103
|
+
const built=buildField(c);
|
|
104
|
+
if(!built)continue;
|
|
105
|
+
form.appendChild(built.field);
|
|
106
|
+
if(built.read)inputs.push(built.read);
|
|
107
|
+
}
|
|
108
|
+
const submit=btn('Submit',null);submit.type='submit';
|
|
109
|
+
const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
|
|
110
|
+
form.appendChild(bar);
|
|
111
|
+
form.addEventListener('submit',e=>{
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
114
|
+
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
115
|
+
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
116
|
+
const variables=Object.create(null);
|
|
117
|
+
for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
|
|
118
|
+
submit.disabled=true;
|
|
119
|
+
complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
|
|
120
|
+
});
|
|
121
|
+
formEl.appendChild(form);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildField(c){
|
|
125
|
+
const type=c&&c.type;
|
|
126
|
+
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
127
|
+
if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
|
|
128
|
+
const key=c&&c.key;
|
|
129
|
+
if(!key)return null;
|
|
130
|
+
const wrap=el('div',{class:'field'});
|
|
131
|
+
const label=typeof c.label==='string'&&c.label?c.label:key;
|
|
132
|
+
if(type==='checkbox'){
|
|
133
|
+
const input=document.createElement('input');input.type='checkbox';
|
|
134
|
+
const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
|
|
135
|
+
wrap.appendChild(lab);
|
|
136
|
+
return {field:wrap,read:()=>({key,value:input.checked})};
|
|
137
|
+
}
|
|
138
|
+
const labelEl=el('label',{},label);
|
|
139
|
+
wrap.appendChild(labelEl);
|
|
140
|
+
let input;
|
|
141
|
+
if(type==='textarea'){input=document.createElement('textarea');}
|
|
142
|
+
else if(type==='select'){
|
|
143
|
+
input=document.createElement('select');
|
|
144
|
+
input.appendChild(el('option',{value:''},'—'));
|
|
145
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
146
|
+
input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if(type==='radio'){
|
|
150
|
+
const name='r'+Math.random().toString(36).slice(2);
|
|
151
|
+
const group=el('div',{});
|
|
152
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
153
|
+
const rlab=el('label',{});
|
|
154
|
+
const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
|
|
155
|
+
rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
|
|
156
|
+
group.appendChild(rlab);
|
|
157
|
+
}
|
|
158
|
+
wrap.appendChild(group);
|
|
159
|
+
return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
|
|
160
|
+
}
|
|
161
|
+
else{
|
|
162
|
+
input=document.createElement('input');
|
|
163
|
+
input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
|
|
164
|
+
}
|
|
165
|
+
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
166
|
+
const fieldId='f'+Math.random().toString(36).slice(2);
|
|
167
|
+
input.id=fieldId;labelEl.setAttribute('for',fieldId);
|
|
168
|
+
wrap.appendChild(input);
|
|
169
|
+
const isNumber=type==='number';
|
|
170
|
+
return {field:wrap,read:()=>{
|
|
171
|
+
const raw=input.value;
|
|
172
|
+
if(raw===''||raw==null)return null;
|
|
173
|
+
if(isNumber){
|
|
174
|
+
const n=Number(raw);
|
|
175
|
+
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
176
|
+
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
177
|
+
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
178
|
+
return Number.isFinite(n)?{key,value:n}:null;
|
|
179
|
+
}
|
|
180
|
+
return {key,value:raw};
|
|
181
|
+
}};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function complete(userTaskKey,variables){
|
|
185
|
+
return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
|
|
186
|
+
body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function el(tag,attrs,text){const e=document.createElement(tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;return e;}
|
|
190
|
+
function btn(label,onClick){const b=document.createElement('button');b.type='button';b.textContent=label;if(onClick)b.addEventListener('click',onClick);return b;}
|
|
191
|
+
|
|
192
|
+
loadTasks();
|
|
42
193
|
</script>`;
|
|
43
194
|
}
|
|
44
195
|
|
|
@@ -56,7 +207,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
56
207
|
method: "GET",
|
|
57
208
|
path: base,
|
|
58
209
|
source: "surface:taskInbox",
|
|
59
|
-
handler: () => html(inboxPage(
|
|
210
|
+
handler: () => html(inboxPage()),
|
|
60
211
|
});
|
|
61
212
|
routes.push({
|
|
62
213
|
method: "GET",
|
|
@@ -68,6 +219,28 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
68
219
|
return json(tasks);
|
|
69
220
|
},
|
|
70
221
|
});
|
|
222
|
+
routes.push({
|
|
223
|
+
method: "GET",
|
|
224
|
+
path: `${base}/api/form`,
|
|
225
|
+
source: "surface:taskInbox",
|
|
226
|
+
handler: async (req) => {
|
|
227
|
+
const formKey = req.query.get("formKey") ?? undefined;
|
|
228
|
+
const formId = req.query.get("formId") ?? undefined;
|
|
229
|
+
// Reject only when *neither* identifier is present. Presence follows getForm's
|
|
230
|
+
// canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
|
|
231
|
+
// whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
|
|
232
|
+
// truthiness check and returning a spurious 204. The raw values are still passed
|
|
233
|
+
// through unchanged — resolving a blank key to its `formId` fallback is getForm's
|
|
234
|
+
// single responsibility.
|
|
235
|
+
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
236
|
+
return json({ error: "formKey or formId required" }, 400);
|
|
237
|
+
const form = await app.engine.getForm({ formKey, formId });
|
|
238
|
+
// A task whose form can't be resolved returns 204: the client renders the
|
|
239
|
+
// no-form fallback rather than erroring.
|
|
240
|
+
if (!form) return noContent();
|
|
241
|
+
return json(form);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
71
244
|
routes.push({
|
|
72
245
|
method: "POST",
|
|
73
246
|
path: `${base}/api/complete`,
|
|
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
|
|
|
30
30
|
this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
31
31
|
}
|
|
32
32
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
33
|
+
async getForm(): Promise<null> { return null; }
|
|
33
34
|
async completeUserTask(): Promise<void> {}
|
|
34
35
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
35
36
|
async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
|
|
@@ -15,6 +15,7 @@ class MiniEngine implements EngineClient {
|
|
|
15
15
|
async cancelInstance(): Promise<void> {}
|
|
16
16
|
async publishMessage(): Promise<void> {}
|
|
17
17
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
18
|
+
async getForm(): Promise<null> { return null; }
|
|
18
19
|
async completeUserTask(): Promise<void> {}
|
|
19
20
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
20
21
|
async close(): Promise<void> {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { normalizeRoutePath } from "./router.ts";
|
|
3
|
+
import { noContent, normalizeRoutePath } from "./router.ts";
|
|
4
4
|
|
|
5
5
|
test("normalizeRoutePath ensures a leading slash and trims trailing slashes", () => {
|
|
6
6
|
assert.equal(normalizeRoutePath("hooks/x", "/fallback"), "/hooks/x");
|
|
@@ -20,3 +20,12 @@ test("normalizeRoutePath falls back when value is missing or collapses to root",
|
|
|
20
20
|
assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
|
|
21
21
|
assert.equal(normalizeRoutePath("", "/tasks"), "/tasks");
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
test("noContent emits a 204 with an empty body and no content-type", () => {
|
|
25
|
+
const res = noContent();
|
|
26
|
+
assert.equal(res.status, 204);
|
|
27
|
+
// 204 must carry no body: a "null" (or any) payload violates the semantics and
|
|
28
|
+
// makes a body-parsing client throw. Guard against a json(null, 204) regression.
|
|
29
|
+
assert.equal(res.body, "");
|
|
30
|
+
assert.equal(res.headers?.["content-type"], undefined);
|
|
31
|
+
});
|