@nanobpm/urban 0.41.0 → 0.43.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/dist/runtime/core/modules/api.d.ts +27 -0
- package/dist/runtime/core/modules/api.js +67 -13
- package/dist/runtime/core/modules/cancel.d.ts +8 -4
- package/dist/runtime/core/modules/cancel.js +23 -11
- package/dist/runtime/core/modules/pages.js +229 -9
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +3 -0
- package/package.json +2 -2
- package/src/runtime/core/logger.test.ts +11 -0
- package/src/runtime/core/modules/api.test.ts +129 -4
- package/src/runtime/core/modules/api.ts +72 -13
- package/src/runtime/core/modules/cancel.test.ts +28 -0
- package/src/runtime/core/modules/cancel.ts +23 -11
- package/src/runtime/core/modules/pages.test.ts +120 -0
- package/src/runtime/core/modules/pages.ts +229 -9
- package/src/runtime/index.ts +4 -2
|
@@ -6,7 +6,7 @@ import type { AppManifest } from "../manifest.ts";
|
|
|
6
6
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
7
7
|
import { makeRouter } from "../router.ts";
|
|
8
8
|
import { DataLayer } from "./datasource.ts";
|
|
9
|
-
import { mountApi, resolveOperationHandler, apiDocsPath, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
|
+
import { mountApi, resolveOperationHandler, apiDocsPath, deriveSwaggerUrls, NotImplemented, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
10
10
|
|
|
11
11
|
function req(
|
|
12
12
|
method: string,
|
|
@@ -746,12 +746,137 @@ test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
|
746
746
|
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
747
747
|
const body = res.body ?? "";
|
|
748
748
|
assert.match(body, /swagger-ui/);
|
|
749
|
-
// The
|
|
750
|
-
//
|
|
751
|
-
|
|
749
|
+
// The page derives ABSOLUTE spec + server URLs from window.location (issue #151 + the trailing-
|
|
750
|
+
// slash 404): the embedded derivation is passed the mount-root docs base, operations base, and a
|
|
751
|
+
// document-relative fallback. It must never hard-code a root-absolute "/app/api-docs/openapi.json"
|
|
752
|
+
// that would escape the Nano console reverse-proxy prefix.
|
|
753
|
+
assert.match(body, /window\.location\.href/);
|
|
754
|
+
assert.match(body, /"\/app\/api-docs"/); // docsBase handed to the client derivation
|
|
755
|
+
assert.match(body, /"\/app\/api"/); // operations base handed to the client derivation
|
|
756
|
+
assert.match(body, /"api-docs\/openapi\.json"/); // document-relative fallback
|
|
752
757
|
assert.doesNotMatch(body, /url: "\/app\/api-docs\/openapi\.json"/);
|
|
753
758
|
});
|
|
754
759
|
|
|
760
|
+
test("the rendered docs page runs its derivation end-to-end and targets the app through the proxy", async () => {
|
|
761
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
762
|
+
const res = await router(req("GET", "/app/api-docs"));
|
|
763
|
+
const body = res.body ?? "";
|
|
764
|
+
// Extract the inline bootstrap <script> (the one that is NOT the swagger-ui-bundle src include).
|
|
765
|
+
const scripts = [...body.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
|
766
|
+
assert.ok(scripts.length >= 1, "expected an inline bootstrap script");
|
|
767
|
+
const boot = scripts[scripts.length - 1];
|
|
768
|
+
// 1) It must be syntactically valid JS (guards the `${derive.toString()}` template interpolation).
|
|
769
|
+
const runBoot = new Function("window", "SwaggerUIBundle", "fetch", boot);
|
|
770
|
+
// 2) Execute it in a mocked browser at the EXACT reported failing URL (proxy prefix + trailing
|
|
771
|
+
// slash) and assert the servers override Swagger UI receives is the app's /app/api under the
|
|
772
|
+
// proxy — never the /app/api-docs/api that produced the 404.
|
|
773
|
+
const specDoc = { openapi: "3.0.3", paths: {}, servers: [{ url: "api" }] };
|
|
774
|
+
let received: unknown;
|
|
775
|
+
const swaggerStub = (opts: { spec?: { servers?: unknown } }): unknown => {
|
|
776
|
+
received = opts.spec?.servers;
|
|
777
|
+
return {};
|
|
778
|
+
};
|
|
779
|
+
const proxySlashUrl = "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/";
|
|
780
|
+
const win: { location: { href: string }; ui?: unknown } = { location: { href: proxySlashUrl } };
|
|
781
|
+
let fetched = "";
|
|
782
|
+
const fetchStub = (url: string): Promise<{ ok: boolean; json: () => Promise<unknown> }> => {
|
|
783
|
+
fetched = url;
|
|
784
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve(specDoc) });
|
|
785
|
+
};
|
|
786
|
+
runBoot(win, swaggerStub, fetchStub);
|
|
787
|
+
await new Promise((r) => setTimeout(r, 0)); // let the fetch().then microtasks settle
|
|
788
|
+
assert.equal(
|
|
789
|
+
fetched,
|
|
790
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
791
|
+
"spec is fetched at an absolute, proxy-prefixed URL (immune to the trailing slash)",
|
|
792
|
+
);
|
|
793
|
+
assert.deepEqual(received, [
|
|
794
|
+
{ url: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api" },
|
|
795
|
+
// ^ Try it out now targets the app's operations base under the proxy, not /app/api-docs/api.
|
|
796
|
+
]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("deriveSwaggerUrls: absolute spec+server from the page URL, immune to trailing slash + proxy prefix", () => {
|
|
800
|
+
const docsBase = "/app/api-docs";
|
|
801
|
+
const apiBase = "/app/api";
|
|
802
|
+
const rel = "api-docs/openapi.json";
|
|
803
|
+
// Direct CLI (:3000), no trailing slash — the canonical case.
|
|
804
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs", docsBase, apiBase, rel), {
|
|
805
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
806
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
807
|
+
});
|
|
808
|
+
// Direct CLI WITH a trailing slash — a relative "servers: api" used to resolve to
|
|
809
|
+
// /app/api-docs/api here (the reported 404). The absolute derivation is unaffected.
|
|
810
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs/", docsBase, apiBase, rel), {
|
|
811
|
+
specUrl: "http://127.0.0.1:3000/app/api-docs/openapi.json",
|
|
812
|
+
server: "http://127.0.0.1:3000/app/api",
|
|
813
|
+
});
|
|
814
|
+
// Under the Nano console reverse proxy — the mount prefix is preserved in both URLs.
|
|
815
|
+
assert.deepEqual(
|
|
816
|
+
deriveSwaggerUrls(
|
|
817
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs",
|
|
818
|
+
docsBase,
|
|
819
|
+
apiBase,
|
|
820
|
+
rel,
|
|
821
|
+
),
|
|
822
|
+
{
|
|
823
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
824
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
// Proxy WITH a trailing slash — the exact failure the user reported. Now correct.
|
|
828
|
+
assert.deepEqual(
|
|
829
|
+
deriveSwaggerUrls(
|
|
830
|
+
"http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/",
|
|
831
|
+
docsBase,
|
|
832
|
+
apiBase,
|
|
833
|
+
rel,
|
|
834
|
+
),
|
|
835
|
+
{
|
|
836
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api-docs/openapi.json",
|
|
837
|
+
server: "http://127.0.0.1:8080/console/app-view/Nano_Workforce/app/api",
|
|
838
|
+
},
|
|
839
|
+
);
|
|
840
|
+
// A query string / fragment on the docs URL is ignored (pathname only).
|
|
841
|
+
assert.equal(
|
|
842
|
+
deriveSwaggerUrls("http://127.0.0.1:3000/app/api-docs?x=1#y", docsBase, apiBase, rel).server,
|
|
843
|
+
"http://127.0.0.1:3000/app/api",
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("deriveSwaggerUrls: a custom docs path keeps the proxy prefix too", () => {
|
|
848
|
+
// A string `docs` override lands the page at a root-level `/help`; the derivation still recovers
|
|
849
|
+
// the (proxy-prefixed) mount root by stripping that suffix and re-appending the operations base.
|
|
850
|
+
assert.deepEqual(
|
|
851
|
+
deriveSwaggerUrls(
|
|
852
|
+
"http://127.0.0.1:8080/console/app-view/App/help/",
|
|
853
|
+
"/help",
|
|
854
|
+
"/app/api",
|
|
855
|
+
"help/openapi.json",
|
|
856
|
+
),
|
|
857
|
+
{
|
|
858
|
+
specUrl: "http://127.0.0.1:8080/console/app-view/App/help/openapi.json",
|
|
859
|
+
server: "http://127.0.0.1:8080/console/app-view/App/app/api",
|
|
860
|
+
},
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("deriveSwaggerUrls: falls back to the document-relative spec when the path doesn't match or href is bad", () => {
|
|
865
|
+
const docsBase = "/app/api-docs";
|
|
866
|
+
const apiBase = "/app/api";
|
|
867
|
+
const rel = "api-docs/openapi.json";
|
|
868
|
+
// Page path doesn't end in docsBase (unexpected) → keep the relative fallback, no server override.
|
|
869
|
+
assert.deepEqual(deriveSwaggerUrls("http://127.0.0.1:3000/elsewhere", docsBase, apiBase, rel), {
|
|
870
|
+
specUrl: rel,
|
|
871
|
+
server: null,
|
|
872
|
+
});
|
|
873
|
+
// Malformed href → same safe fallback (no throw).
|
|
874
|
+
assert.deepEqual(deriveSwaggerUrls("not a url", docsBase, apiBase, rel), {
|
|
875
|
+
specUrl: rel,
|
|
876
|
+
server: null,
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
755
880
|
test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
|
|
756
881
|
const { router } = build({ spec: "openapi.json" }, {});
|
|
757
882
|
// The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
|
|
@@ -70,10 +70,54 @@ function escapeHtml(s: string): string {
|
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
|
|
75
|
+
* page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
|
|
76
|
+
* `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
|
|
77
|
+
*
|
|
78
|
+
* Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
|
|
79
|
+
* trailing slash — reverse proxies and some webviews append one), and behind the Nano console
|
|
80
|
+
* proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
|
|
81
|
+
* relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
|
|
82
|
+
* "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
|
|
83
|
+
* app never sees the proxy prefix, so `window.location` in the browser is the only place with the
|
|
84
|
+
* full mount path.
|
|
85
|
+
*
|
|
86
|
+
* We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
|
|
87
|
+
* the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
|
|
88
|
+
* Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
|
|
89
|
+
* page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
|
|
90
|
+
* document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
|
|
91
|
+
*
|
|
92
|
+
* This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
|
|
93
|
+
* shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
|
|
94
|
+
* and free of template literals / `${}` (it is interpolated into a template literal).
|
|
95
|
+
*/
|
|
96
|
+
export function deriveSwaggerUrls(
|
|
97
|
+
pageHref: string,
|
|
98
|
+
docsBase: string,
|
|
99
|
+
apiBase: string,
|
|
100
|
+
relSpec: string,
|
|
101
|
+
): { specUrl: string; server: string | null } {
|
|
102
|
+
try {
|
|
103
|
+
const loc = new URL(pageHref);
|
|
104
|
+
const path = loc.pathname.replace(/\/+$/, "");
|
|
105
|
+
if (path.length >= docsBase.length && path.slice(path.length - docsBase.length) === docsBase) {
|
|
106
|
+
const mount = loc.origin + path.slice(0, path.length - docsBase.length);
|
|
107
|
+
return { specUrl: mount + docsBase + "/openapi.json", server: mount + apiBase };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed href — fall through to the document-relative fallback below.
|
|
111
|
+
}
|
|
112
|
+
return { specUrl: relSpec, server: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
74
|
-
* unreadable (
|
|
75
|
-
* which keeps the docs route decoupled from spec-load failures.
|
|
76
|
-
|
|
116
|
+
* unreadable (on a fetch/parse failure it hands `specUrl` to Swagger UI, which surfaces the error
|
|
117
|
+
* in-page), which keeps the docs route decoupled from spec-load failures. The client derives
|
|
118
|
+
* absolute spec + server URLs from `window.location` via `deriveSwaggerUrls` so "Try it out"
|
|
119
|
+
* targets the app regardless of a trailing slash on the docs URL or a reverse-proxy prefix. */
|
|
120
|
+
function swaggerUiPage(title: string, specUrl: string, docsBase: string, apiBase: string): string {
|
|
77
121
|
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
78
122
|
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
79
123
|
return `<!doctype html>
|
|
@@ -89,7 +133,22 @@ function swaggerUiPage(title: string, specUrl: string): string {
|
|
|
89
133
|
<div id="swagger-ui"></div>
|
|
90
134
|
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
91
135
|
<script>
|
|
92
|
-
|
|
136
|
+
(function () {
|
|
137
|
+
var derive = ${deriveSwaggerUrls.toString()};
|
|
138
|
+
var urls = derive(window.location.href, ${JSON.stringify(docsBase)}, ${JSON.stringify(apiBase)}, ${JSON.stringify(specUrl)});
|
|
139
|
+
function boot(opts) { window.ui = SwaggerUIBundle(opts); }
|
|
140
|
+
fetch(urls.specUrl)
|
|
141
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function (spec) {
|
|
143
|
+
if (spec && urls.server) { spec.servers = [{ url: urls.server }]; }
|
|
144
|
+
boot(spec
|
|
145
|
+
? { spec: spec, dom_id: '#swagger-ui', deepLinking: true }
|
|
146
|
+
: { url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
147
|
+
})
|
|
148
|
+
.catch(function () {
|
|
149
|
+
boot({ url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
|
|
150
|
+
});
|
|
151
|
+
})();
|
|
93
152
|
</script>
|
|
94
153
|
</body>
|
|
95
154
|
</html>`;
|
|
@@ -312,12 +371,13 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
312
371
|
const dir = binding.dir ?? "operations";
|
|
313
372
|
const surfaceEject = binding.eject === true;
|
|
314
373
|
const specRoutePath = `${docsBase}/openapi.json`;
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
374
|
+
// Fallback values for the RAW served spec + docs page. The interactive Swagger UI computes
|
|
375
|
+
// ABSOLUTE spec + server URLs client-side from `window.location` (see `deriveSwaggerUrls`), which
|
|
376
|
+
// is immune to a trailing slash on the docs URL and to the Nano console reverse-proxy prefix
|
|
377
|
+
// (/console/app-view/<name>/). These document-relative values remain (a) the client's fallback
|
|
378
|
+
// when the page path unexpectedly doesn't end in `docsBase`, and (b) what a non-interactive
|
|
379
|
+
// consumer reading `openapi.json` directly sees. They still rebase onto the app's mount root
|
|
380
|
+
// rather than using a root-absolute "/app/…" that would escape the proxy prefix (issue #151).
|
|
321
381
|
const lastSegment = (p: string): string => p.split("/").filter(Boolean).pop() ?? "";
|
|
322
382
|
const parentPath = (p: string): string => {
|
|
323
383
|
const parts = p.split("/").filter(Boolean);
|
|
@@ -328,8 +388,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
328
388
|
const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
|
|
329
389
|
// `servers` can only be relativized when the operations `base` shares the docs page's parent
|
|
330
390
|
// directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
|
|
331
|
-
// different parent, keep the root-absolute base
|
|
332
|
-
// the documented limitation in #151).
|
|
391
|
+
// different parent, keep the root-absolute base for the raw spec.
|
|
333
392
|
const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
|
|
334
393
|
|
|
335
394
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
@@ -741,7 +800,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
741
800
|
return json({ ...d, servers: [{ url: serversUrl }] });
|
|
742
801
|
};
|
|
743
802
|
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
744
|
-
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl));
|
|
803
|
+
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specDocUrl, docsBase, base));
|
|
745
804
|
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
746
805
|
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
747
806
|
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
@@ -311,3 +311,31 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
|
|
|
311
311
|
await h.close();
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
test("a thrown cancel whose verify read comes back empty (gone) is an unverified failure, not a false success", async () => {
|
|
316
|
+
// The engine-restart trap: the cancel POST throws (dead keep-alive socket / the brief window
|
|
317
|
+
// the gateway is down), then the verify search hits the just-restarted engine whose query store
|
|
318
|
+
// has NOT yet rehydrated the still-running instance — so the read comes back empty ("gone").
|
|
319
|
+
// A throw is not a committed 204, and absence from the read model is not proof of termination, so
|
|
320
|
+
// reporting success here is the "worst possible lie": the button says cancelled while the run
|
|
321
|
+
// keeps going. It must be an honest ok:false so the caller surfaces it and the user retries.
|
|
322
|
+
const { engine } = cancelEngine({
|
|
323
|
+
states: {}, // read model transiently empty (mid-restart), though instance "100" is still live
|
|
324
|
+
onCancel: () => {
|
|
325
|
+
throw new Error("fetch failed");
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const h = await withHarness(engine);
|
|
329
|
+
try {
|
|
330
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
331
|
+
assert.equal(r.ok, false); // NOT a false success
|
|
332
|
+
assert.equal(r.state, "gone");
|
|
333
|
+
assert.equal(r.reconciled, 0);
|
|
334
|
+
assert.match(r.error ?? "", /fetch failed/);
|
|
335
|
+
const row = await h.table.get("100");
|
|
336
|
+
assert.equal(row?.status, "converging"); // never flip a possibly-live run to a terminal status
|
|
337
|
+
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
338
|
+
} finally {
|
|
339
|
+
await h.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
15
|
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
|
-
// lags at ACTIVE), while a throw
|
|
17
|
-
//
|
|
18
|
-
// never drift from the poll reconciler's.
|
|
16
|
+
// lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
|
|
17
|
+
// or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
|
|
18
|
+
// `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
|
|
19
19
|
|
|
20
20
|
import type { AppApi } from "../context.ts";
|
|
21
21
|
import type { InstanceTracking } from "../manifest.ts";
|
|
@@ -44,10 +44,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
|
44
44
|
* Outcome rules:
|
|
45
45
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
46
46
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
47
|
-
* - `cancelInstance` throws but the instance reads back
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
48
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
49
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
50
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
51
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
52
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
53
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
54
|
+
* termination, so success is never inferred from it.
|
|
51
55
|
*
|
|
52
56
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
53
57
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -68,11 +72,19 @@ export async function cancelInstanceReconciling(
|
|
|
68
72
|
|
|
69
73
|
const state = await readState(api, key, threw);
|
|
70
74
|
|
|
71
|
-
if (threw && state
|
|
72
|
-
// The cancel
|
|
73
|
-
// terminal
|
|
74
|
-
|
|
75
|
+
if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
|
|
76
|
+
// The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
|
|
77
|
+
// confirm a terminal state. Two shapes land here, both honest failures:
|
|
78
|
+
// • state === "ACTIVE": the instance is demonstrably still running.
|
|
79
|
+
// • state === "gone": the read model has no record of the key. A throw makes this
|
|
80
|
+
// ambiguous — after an engine restart the query store can transiently lack a live instance
|
|
81
|
+
// before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
|
|
82
|
+
// Trust neither as success: claiming a cancel that may not have happened (while the run keeps
|
|
83
|
+
// executing) is the worst possible lie for a cancel button. Report ok:false so the caller
|
|
84
|
+
// surfaces it and retries; never flip a tracked row to terminal while the instance may live.
|
|
85
|
+
api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
|
|
75
86
|
processInstanceKey: key,
|
|
87
|
+
state,
|
|
76
88
|
error: errorMessage(threw),
|
|
77
89
|
});
|
|
78
90
|
return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
|
|
@@ -138,6 +138,36 @@ test("the shell styles the app through the shared --nano-* token contract", asyn
|
|
|
138
138
|
assert.match(html, /:root:not\(\[data-appearance\]\)/);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("the warn badge stays legible in light appearance", async () => {
|
|
142
|
+
const res = await dispatch("GET", "/");
|
|
143
|
+
const html = res.body ?? "";
|
|
144
|
+
// The warn badge sits on --nano-warn, which is a bright amber in dark mode
|
|
145
|
+
// (dark text reads well) but a dark orange in light mode (#b45309), where the
|
|
146
|
+
// same dark text collapses to near-zero contrast. Both light paths — the
|
|
147
|
+
// console-themed [data-appearance="light"] and the standalone
|
|
148
|
+
// prefers-color-scheme fallback — must flip the warn label to white so it
|
|
149
|
+
// stays readable.
|
|
150
|
+
assert.match(html, /:root\[data-appearance="light"\]\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}/);
|
|
151
|
+
assert.match(
|
|
152
|
+
html,
|
|
153
|
+
/@media \(prefers-color-scheme: light\)\s*\{\s*:root:not\(\[data-appearance\]\)\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}\s*\}/,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("grid cells wrap long, space-less values so one cell can't overflow the table", async () => {
|
|
158
|
+
// Regression: a dataGrid cell holding a long space-less value (e.g. a JSON
|
|
159
|
+
// blob or a 40-char SHA) has no soft-wrap opportunity, so with table-layout
|
|
160
|
+
// auto it forces its column to the value's full width and pushes the whole
|
|
161
|
+
// table past the page. The shared grid CSS must let such a cell break so the
|
|
162
|
+
// column can shrink to fit its container.
|
|
163
|
+
const res = await dispatch("GET", "/");
|
|
164
|
+
const html = res.body ?? "";
|
|
165
|
+
const rule = (html.match(/table\.pc-grid th,\s*table\.pc-grid td\s*\{[^}]*\}/) ?? [""])[0];
|
|
166
|
+
assert.ok(rule, "grid th/td rule must be present");
|
|
167
|
+
assert.match(rule, /overflow-wrap:anywhere/);
|
|
168
|
+
assert.match(rule, /max-width:/);
|
|
169
|
+
});
|
|
170
|
+
|
|
141
171
|
test("GET /app/runtime.js serves the renderer module", async () => {
|
|
142
172
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
143
173
|
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
@@ -290,6 +320,24 @@ test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
|
290
320
|
assert.match(js, /rel: "noopener noreferrer"/);
|
|
291
321
|
});
|
|
292
322
|
|
|
323
|
+
test("renderer wires a column's badge to a tone-classed pill shown only when present", async () => {
|
|
324
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
325
|
+
const js = res.body ?? "";
|
|
326
|
+
// A column declaring `badge` renders a compact circular pill (e.g. a red "1"
|
|
327
|
+
// flagging an incident) only when the field value is non-empty; an empty value
|
|
328
|
+
// leaves the cell blank so the column is unobtrusive until it matters. Guard
|
|
329
|
+
// the shape: presence-gated on the trimmed field, tone is allow-listed
|
|
330
|
+
// (default danger), the label defaults to "1", and the full field text becomes
|
|
331
|
+
// the tooltip.
|
|
332
|
+
assert.match(js, /if\s*\(col\.badge\)/);
|
|
333
|
+
assert.match(js, /text\.trim\(\)\s*===\s*""/);
|
|
334
|
+
assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
|
|
335
|
+
assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
|
|
336
|
+
assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
|
|
337
|
+
assert.match(js, /title:\s*text/);
|
|
338
|
+
assert.match(js, /"aria-label":\s*text/);
|
|
339
|
+
});
|
|
340
|
+
|
|
293
341
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
294
342
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
295
343
|
const js = res.body ?? "";
|
|
@@ -353,6 +401,22 @@ test("the renderer honours numeric actionForm fields", async () => {
|
|
|
353
401
|
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
354
402
|
});
|
|
355
403
|
|
|
404
|
+
test("the renderer honours boolean (checkbox) actionForm fields", async () => {
|
|
405
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
406
|
+
const js = res.body ?? "";
|
|
407
|
+
// Fields declared `type: "checkbox"` render a real <input type="checkbox">…
|
|
408
|
+
assert.match(js, /f\.type === "checkbox"/);
|
|
409
|
+
assert.match(js, /el\("input", \{ type: "checkbox" \}\)/);
|
|
410
|
+
// …whose default checked state comes from `default`/`checked` on the field…
|
|
411
|
+
assert.match(js, /f\.default === true \|\| f\.checked === true/);
|
|
412
|
+
// …and whose submitted value is always a real boolean (never a string), so a
|
|
413
|
+
// strict `=== true` check on the action side sees the intended value.
|
|
414
|
+
assert.match(js, /variables\[k\] = input\.checked === true;/);
|
|
415
|
+
// A post-submit reset restores a checkbox to its declared default (read off the
|
|
416
|
+
// input's defaultChecked) rather than clearing `.value` (a no-op for a checkbox).
|
|
417
|
+
assert.match(js, /input\.checked = input\.defaultChecked;/);
|
|
418
|
+
});
|
|
419
|
+
|
|
356
420
|
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
357
421
|
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
358
422
|
// body, and the collapsed state is written to localStorage keyed by the home
|
|
@@ -464,6 +528,62 @@ test("the renderer ships a nav node (menu bar / rail) with in-app + external lin
|
|
|
464
528
|
assert.match(js, /pc-main-col/);
|
|
465
529
|
});
|
|
466
530
|
|
|
531
|
+
test("the renderer ships a button node that opens a copy-pasteable modal", async () => {
|
|
532
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
533
|
+
const js = res.body ?? "";
|
|
534
|
+
// button is a first-class node type.
|
|
535
|
+
assert.match(js, /button: renderButton/);
|
|
536
|
+
assert.match(js, /function renderButton\(node\)/);
|
|
537
|
+
// A ghost variant renders the muted outline style; a button carries its modal.
|
|
538
|
+
assert.match(js, /p\.variant === "ghost"/);
|
|
539
|
+
assert.match(js, /btn\.addEventListener\("click", \(\) => openModal\(m\)\)/);
|
|
540
|
+
// The modal is appended to <body>, is dismissable, and cleans up its keydown.
|
|
541
|
+
assert.match(js, /function openModal\(m\)/);
|
|
542
|
+
assert.match(js, /class: "pc-modal-overlay"/);
|
|
543
|
+
assert.match(js, /"aria-modal": "true"/);
|
|
544
|
+
assert.match(js, /if \(ev\.key === "Escape"\) \{ close\(\); return; \}/);
|
|
545
|
+
assert.match(js, /document\.removeEventListener\("keydown", onKey\)/);
|
|
546
|
+
// {{appBase}} in copy text is rebased onto the absolute mount root so an
|
|
547
|
+
// external agent gets a fetchable URL.
|
|
548
|
+
assert.match(js, /function resolveCopyText\(text\)/);
|
|
549
|
+
assert.match(js, /split\("\{\{appBase\}\}"\)\.join\(APP_BASE\.toString\(\)\)/);
|
|
550
|
+
// Copy is clipboard-first with a sandbox-safe execCommand fallback.
|
|
551
|
+
assert.match(js, /async function copyToClipboard\(text\)/);
|
|
552
|
+
assert.match(js, /navigator\.clipboard\.writeText/);
|
|
553
|
+
assert.match(js, /document\.execCommand\("copy"\)/);
|
|
554
|
+
// The fallback textarea is torn down in a finally so a throwing select()/
|
|
555
|
+
// execCommand never leaves a detached node accreting in <body> on retry.
|
|
556
|
+
assert.match(js, /finally \{ ta\.remove\(\); \}/);
|
|
557
|
+
// The dialog carries an accessible name (labelled by its title, or a fallback
|
|
558
|
+
// aria-label) so screen readers don't announce an unnamed dialog.
|
|
559
|
+
assert.match(js, /"aria-labelledby": titleId/);
|
|
560
|
+
assert.match(js, /"aria-label": "Dialog"/);
|
|
561
|
+
// An open modal is registered with teardown() so a page switch disposes it
|
|
562
|
+
// instead of leaking a stale overlay + document-level keydown listener.
|
|
563
|
+
assert.match(js, /disposers\.push\(close\)/);
|
|
564
|
+
// A manual close removes its own disposer entry, so repeated open/close cycles
|
|
565
|
+
// don't accrete dead close() closures on the disposers stack.
|
|
566
|
+
assert.match(js, /disposers\.indexOf\(close\)/);
|
|
567
|
+
assert.match(js, /disposers\.splice\(i, 1\)/);
|
|
568
|
+
// Focus is restored to the previously-focused element on close, and Tab is
|
|
569
|
+
// trapped inside the dialog so it can't reach the inert page behind the overlay.
|
|
570
|
+
assert.match(js, /const prevFocus = document\.activeElement/);
|
|
571
|
+
assert.match(js, /if \(ev\.key !== "Tab"\) return/);
|
|
572
|
+
assert.match(js, /ev\.preventDefault\(\); last\.focus\(\)/);
|
|
573
|
+
assert.match(js, /ev\.preventDefault\(\); first\.focus\(\)/);
|
|
574
|
+
// Only one modal opens at a time: a second open (e.g. double-click) is dropped
|
|
575
|
+
// so overlays and document-level keydown listeners never stack.
|
|
576
|
+
assert.match(js, /let modalOpen = false/);
|
|
577
|
+
assert.match(js, /if \(modalOpen\) return/);
|
|
578
|
+
assert.match(js, /modalOpen = true/);
|
|
579
|
+
assert.match(js, /modalOpen = false/);
|
|
580
|
+
// The aria-labelledby title id is unique per modal instance so it can't
|
|
581
|
+
// collide with a pre-existing page id and mis-associate the label.
|
|
582
|
+
assert.match(js, /let modalSeq = 0/);
|
|
583
|
+
assert.match(js, /"pc-modal-title-" \+ \(\+\+modalSeq\)/);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
|
|
467
587
|
test("GET /app/data/<source>/<table> returns rows", async () => {
|
|
468
588
|
const res = await dispatch("GET", "/app/data/app/orders");
|
|
469
589
|
assert.equal(res.status, 200);
|