@karmaniverous/jeeves-server 3.0.0-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/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed diagram render cache.
|
|
3
|
+
*
|
|
4
|
+
* Cache key = sha256(type + '\\0' + source). Cached artifacts are SVG strings
|
|
5
|
+
* stored as files in a single directory. No timestamp comparison needed:
|
|
6
|
+
* if the source changes, the hash changes → automatic cache miss.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
let cacheDir = null;
|
|
12
|
+
/**
|
|
13
|
+
* Initialize the cache directory. Call once at startup.
|
|
14
|
+
* Defaults to `.diagram-cache` in the current working directory.
|
|
15
|
+
*/
|
|
16
|
+
export function initDiagramCache(dir) {
|
|
17
|
+
cacheDir = dir ?? path.resolve('.diagram-cache');
|
|
18
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Get the cache directory path (for diagnostics / health endpoint).
|
|
22
|
+
*/
|
|
23
|
+
export function getDiagramCacheDir() {
|
|
24
|
+
return cacheDir;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compute the cache key for a diagram.
|
|
28
|
+
*/
|
|
29
|
+
function cacheKey(type, source) {
|
|
30
|
+
return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Look up a cached diagram. Returns the content as a string or null on miss.
|
|
34
|
+
* @param format - Output format extension (e.g. 'svg', 'png', 'pdf'). Defaults to 'svg'.
|
|
35
|
+
*/
|
|
36
|
+
export function getCachedDiagram(type, source, format = 'svg') {
|
|
37
|
+
if (!cacheDir)
|
|
38
|
+
return null;
|
|
39
|
+
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(file, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Look up a cached diagram as a Buffer (for binary formats like PNG/PDF).
|
|
49
|
+
* @param format - Output format extension (e.g. 'png', 'pdf').
|
|
50
|
+
*/
|
|
51
|
+
export function getCachedDiagramBuffer(type, source, format) {
|
|
52
|
+
if (!cacheDir)
|
|
53
|
+
return null;
|
|
54
|
+
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
55
|
+
try {
|
|
56
|
+
return fs.readFileSync(file);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Store a rendered diagram in the cache (string content).
|
|
64
|
+
* @param format - Output format extension. Defaults to 'svg'.
|
|
65
|
+
*/
|
|
66
|
+
export function cacheDiagram(type, source, content, format = 'svg') {
|
|
67
|
+
if (!cacheDir)
|
|
68
|
+
return;
|
|
69
|
+
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.error('[diagramCache] write failed:', err.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Store a rendered diagram in the cache (binary content).
|
|
79
|
+
* @param format - Output format extension (e.g. 'png', 'pdf').
|
|
80
|
+
*/
|
|
81
|
+
export function cacheDiagramBuffer(type, source, buffer, format) {
|
|
82
|
+
if (!cacheDir)
|
|
83
|
+
return;
|
|
84
|
+
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(file, buffer);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error('[diagramCache] write failed:', err.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get a cached diagram or render it and cache the result.
|
|
94
|
+
* Eliminates the repeated getCachedDiagram → render → cacheDiagram pattern.
|
|
95
|
+
*/
|
|
96
|
+
export async function getOrRenderDiagram(type, source, renderFn, format = 'svg') {
|
|
97
|
+
const cached = getCachedDiagram(type, source, format);
|
|
98
|
+
if (cached)
|
|
99
|
+
return cached;
|
|
100
|
+
const result = await renderFn();
|
|
101
|
+
if (result)
|
|
102
|
+
cacheDiagram(type, source, result, format);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded diagram support for Mermaid and PlantUML code blocks in markdown.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: parseMarkdown() calls registerDiagram() which stores the source
|
|
5
|
+
* and returns a placeholder div. The client lazily fetches rendered SVGs
|
|
6
|
+
* via GET /api/diagram/:type/:hash.svg, which renders on-demand and caches.
|
|
7
|
+
*
|
|
8
|
+
* For PDF/DOCX export (server-side rendering), renderEmbeddedDiagrams()
|
|
9
|
+
* replaces placeholders with inline SVGs synchronously.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { getOrRenderDiagram } from './diagramCache.js';
|
|
13
|
+
import { renderMermaidFromSource } from './mermaid.js';
|
|
14
|
+
import { renderPlantUmlFromSource } from './plantuml.js';
|
|
15
|
+
/** Placeholder format used by the markdown renderer */
|
|
16
|
+
const PLACEHOLDER_RE = /<!--DIAGRAM:(mermaid|plantuml):([a-f0-9]{64})-->/g;
|
|
17
|
+
/**
|
|
18
|
+
* In-flight diagram sources, keyed by content hash.
|
|
19
|
+
* Entries are cleaned up after a TTL to prevent unbounded growth.
|
|
20
|
+
*/
|
|
21
|
+
const diagramSources = new Map();
|
|
22
|
+
/** TTL for source map entries (10 minutes) */
|
|
23
|
+
const SOURCE_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
/** Periodic cleanup interval */
|
|
25
|
+
let cleanupInterval = null;
|
|
26
|
+
function startCleanup() {
|
|
27
|
+
if (cleanupInterval)
|
|
28
|
+
return;
|
|
29
|
+
cleanupInterval = setInterval(() => {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [hash, entry] of diagramSources) {
|
|
32
|
+
if (now - entry.createdAt > SOURCE_TTL_MS) {
|
|
33
|
+
diagramSources.delete(hash);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, 60_000);
|
|
37
|
+
// Don't keep process alive just for cleanup
|
|
38
|
+
cleanupInterval.unref();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compute content hash matching the cache key format.
|
|
42
|
+
*/
|
|
43
|
+
export function diagramHash(type, source) {
|
|
44
|
+
return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
/** Module-level context directory for the current markdown parse. */
|
|
47
|
+
let currentContextDir;
|
|
48
|
+
/**
|
|
49
|
+
* Set the context directory for diagram registration.
|
|
50
|
+
* Call before parseMarkdown() so registered diagrams know their !include context.
|
|
51
|
+
*/
|
|
52
|
+
export function setDiagramContext(contextDir) {
|
|
53
|
+
currentContextDir = contextDir;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Register a diagram source and return a placeholder HTML div.
|
|
57
|
+
* Called synchronously from the marked renderer.
|
|
58
|
+
*/
|
|
59
|
+
export function registerDiagram(type, source) {
|
|
60
|
+
const hash = diagramHash(type, source);
|
|
61
|
+
diagramSources.set(hash, {
|
|
62
|
+
source,
|
|
63
|
+
contextDir: currentContextDir,
|
|
64
|
+
createdAt: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
startCleanup();
|
|
67
|
+
// Emit a client-side placeholder that the LazyDiagram component will hydrate
|
|
68
|
+
return `<div class="embedded-diagram-lazy" data-diagram-type="${type}" data-diagram-hash="${hash}"><!--DIAGRAM:${type}:${hash}--></div>\n`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Look up a registered diagram source by hash.
|
|
72
|
+
* Used by the /api/diagram endpoint for on-demand rendering.
|
|
73
|
+
*/
|
|
74
|
+
export function getDiagramSource(hash) {
|
|
75
|
+
const entry = diagramSources.get(hash);
|
|
76
|
+
if (!entry)
|
|
77
|
+
return null;
|
|
78
|
+
return { type: '', source: entry.source, contextDir: entry.contextDir };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render a diagram to SVG (with cache). Used by the /api/diagram endpoint.
|
|
82
|
+
*/
|
|
83
|
+
export async function renderDiagramToSvg(type, source,
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
85
|
+
_contextDir) {
|
|
86
|
+
try {
|
|
87
|
+
return await getOrRenderDiagram(type, source, () => {
|
|
88
|
+
if (type === 'mermaid')
|
|
89
|
+
return renderMermaidFromSource(source);
|
|
90
|
+
if (type === 'plantuml')
|
|
91
|
+
return renderPlantUmlFromSource(source);
|
|
92
|
+
return null;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`[embeddedDiagrams] ${type} render failed:`, err.message);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Replace all diagram placeholders in rendered HTML with inline SVGs.
|
|
102
|
+
* Used for PDF/DOCX export where client-side lazy loading isn't available.
|
|
103
|
+
*/
|
|
104
|
+
export async function renderEmbeddedDiagrams(html, contextDir) {
|
|
105
|
+
const matches = [...html.matchAll(PLACEHOLDER_RE)];
|
|
106
|
+
if (matches.length === 0)
|
|
107
|
+
return html;
|
|
108
|
+
let result = html;
|
|
109
|
+
for (const match of matches) {
|
|
110
|
+
const [placeholder, type, hash] = match;
|
|
111
|
+
const entry = diagramSources.get(hash);
|
|
112
|
+
if (!entry)
|
|
113
|
+
continue;
|
|
114
|
+
const source = entry.source;
|
|
115
|
+
if (!source)
|
|
116
|
+
continue;
|
|
117
|
+
const svg = await renderDiagramToSvg(type, source, contextDir ?? entry.contextDir);
|
|
118
|
+
if (svg) {
|
|
119
|
+
const wrapped = `<div class="embedded-diagram-rendered" data-type="${type}">${svg}</div>`;
|
|
120
|
+
result = result.replace(placeholder, wrapped);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const escaped = escapeHtml(source);
|
|
124
|
+
const errorBlock = `<div class="embedded-diagram-error" data-type="${type}"><div class="diagram-error-label">${type} render failed</div><pre class="hljs"><code>${escaped}</code></pre></div>`;
|
|
125
|
+
result = result.replace(placeholder, errorBlock);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
function escapeHtml(str) {
|
|
131
|
+
return str
|
|
132
|
+
.replace(/&/g, '&')
|
|
133
|
+
.replace(/</g, '<')
|
|
134
|
+
.replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"');
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event logging service - logs all events (matched and unmatched)
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { getConfig } from '../config/index.js';
|
|
7
|
+
import { nowIso } from '../util/formatters.js';
|
|
8
|
+
/**
|
|
9
|
+
* Ensure directory exists
|
|
10
|
+
*/
|
|
11
|
+
function ensureDir(dirPath) {
|
|
12
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Parse JSONL file into entries
|
|
16
|
+
*/
|
|
17
|
+
function parseJsonl(filePath) {
|
|
18
|
+
if (!fs.existsSync(filePath))
|
|
19
|
+
return [];
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
21
|
+
return content
|
|
22
|
+
.split('\n')
|
|
23
|
+
.filter((line) => line.trim())
|
|
24
|
+
.map((line) => JSON.parse(line));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Write entries to JSONL file
|
|
28
|
+
*/
|
|
29
|
+
function writeJsonl(filePath, entries) {
|
|
30
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
|
|
31
|
+
fs.writeFileSync(filePath, content + '\n', 'utf8');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Purge entries older than configured retention period
|
|
35
|
+
*/
|
|
36
|
+
function purgeOldEntries(entries) {
|
|
37
|
+
const { eventLogPurgeMs } = getConfig();
|
|
38
|
+
const cutoff = new Date(Date.now() - eventLogPurgeMs);
|
|
39
|
+
return entries.filter((entry) => new Date(entry.ts) >= cutoff);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Append an event to the log and purge old entries
|
|
43
|
+
*/
|
|
44
|
+
export function logEvent(entry) {
|
|
45
|
+
const { eventLogPath } = getConfig();
|
|
46
|
+
ensureDir(path.dirname(eventLogPath));
|
|
47
|
+
// Read existing entries
|
|
48
|
+
const entries = parseJsonl(eventLogPath);
|
|
49
|
+
// Add new entry with timestamp
|
|
50
|
+
entries.push({ ts: nowIso(), ...entry });
|
|
51
|
+
// Purge old entries
|
|
52
|
+
const purgedEntries = purgeOldEntries(entries);
|
|
53
|
+
// Write back
|
|
54
|
+
writeJsonl(eventLogPath, purgedEntries);
|
|
55
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event log service tests
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { nowIso } from '../util/formatters.js';
|
|
8
|
+
// Test helper functions (isolated from config system)
|
|
9
|
+
function parseJsonl(filePath) {
|
|
10
|
+
if (!fs.existsSync(filePath))
|
|
11
|
+
return [];
|
|
12
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
13
|
+
return content
|
|
14
|
+
.split('\n')
|
|
15
|
+
.filter((line) => line.trim())
|
|
16
|
+
.map((line) => JSON.parse(line));
|
|
17
|
+
}
|
|
18
|
+
function writeJsonl(filePath, entries) {
|
|
19
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
|
|
20
|
+
fs.writeFileSync(filePath, content + '\n', 'utf8');
|
|
21
|
+
}
|
|
22
|
+
function purgeOldEntries(entries, retentionMs) {
|
|
23
|
+
const cutoff = new Date(Date.now() - retentionMs);
|
|
24
|
+
return entries.filter((entry) => new Date(entry.ts) >= cutoff);
|
|
25
|
+
}
|
|
26
|
+
describe('eventLog', () => {
|
|
27
|
+
it('should parse JSONL entries correctly', () => {
|
|
28
|
+
const entry1 = {
|
|
29
|
+
ts: nowIso(),
|
|
30
|
+
event: 'test-event',
|
|
31
|
+
matched: true,
|
|
32
|
+
exitCode: 0,
|
|
33
|
+
durationMs: 123,
|
|
34
|
+
};
|
|
35
|
+
const entry2 = {
|
|
36
|
+
ts: nowIso(),
|
|
37
|
+
event: null,
|
|
38
|
+
matched: false,
|
|
39
|
+
bodyPreview: '{"type":"unknown"}',
|
|
40
|
+
};
|
|
41
|
+
const tempFile = path.join(process.cwd(), 'test-temp-log.jsonl');
|
|
42
|
+
writeJsonl(tempFile, [entry1, entry2]);
|
|
43
|
+
const parsed = parseJsonl(tempFile);
|
|
44
|
+
expect(parsed).toHaveLength(2);
|
|
45
|
+
expect(parsed[0].event).toBe('test-event');
|
|
46
|
+
expect(parsed[0].matched).toBe(true);
|
|
47
|
+
expect(parsed[1].event).toBeNull();
|
|
48
|
+
expect(parsed[1].matched).toBe(false);
|
|
49
|
+
// Cleanup
|
|
50
|
+
fs.unlinkSync(tempFile);
|
|
51
|
+
});
|
|
52
|
+
it('should purge entries older than retention period', () => {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const retentionMs = 86400000; // 1 day
|
|
55
|
+
const oldEntry = {
|
|
56
|
+
ts: new Date(now - 2 * retentionMs).toISOString(),
|
|
57
|
+
event: 'old-event',
|
|
58
|
+
matched: true,
|
|
59
|
+
exitCode: 0,
|
|
60
|
+
durationMs: 100,
|
|
61
|
+
};
|
|
62
|
+
const recentEntry = {
|
|
63
|
+
ts: new Date(now - 0.5 * retentionMs).toISOString(),
|
|
64
|
+
event: 'recent-event',
|
|
65
|
+
matched: true,
|
|
66
|
+
exitCode: 0,
|
|
67
|
+
durationMs: 200,
|
|
68
|
+
};
|
|
69
|
+
const currentEntry = {
|
|
70
|
+
ts: new Date(now).toISOString(),
|
|
71
|
+
event: 'current-event',
|
|
72
|
+
matched: true,
|
|
73
|
+
exitCode: 0,
|
|
74
|
+
durationMs: 300,
|
|
75
|
+
};
|
|
76
|
+
const purged = purgeOldEntries([oldEntry, recentEntry, currentEntry], retentionMs);
|
|
77
|
+
expect(purged).toHaveLength(2);
|
|
78
|
+
expect(purged[0].event).toBe('recent-event');
|
|
79
|
+
expect(purged[1].event).toBe('current-event');
|
|
80
|
+
});
|
|
81
|
+
it('should write JSONL with proper newline formatting', () => {
|
|
82
|
+
const entries = [
|
|
83
|
+
{
|
|
84
|
+
ts: nowIso(),
|
|
85
|
+
event: 'event1',
|
|
86
|
+
matched: true,
|
|
87
|
+
exitCode: 0,
|
|
88
|
+
durationMs: 100,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
ts: nowIso(),
|
|
92
|
+
event: 'event2',
|
|
93
|
+
matched: true,
|
|
94
|
+
exitCode: 1,
|
|
95
|
+
durationMs: 200,
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const tempFile = path.join(process.cwd(), 'test-temp-write.jsonl');
|
|
99
|
+
writeJsonl(tempFile, entries);
|
|
100
|
+
const content = fs.readFileSync(tempFile, 'utf8');
|
|
101
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
102
|
+
expect(lines).toHaveLength(2);
|
|
103
|
+
expect(JSON.parse(lines[0]).event).toBe('event1');
|
|
104
|
+
expect(JSON.parse(lines[1]).event).toBe('event2');
|
|
105
|
+
// Cleanup
|
|
106
|
+
fs.unlinkSync(tempFile);
|
|
107
|
+
});
|
|
108
|
+
it('should handle empty log file', () => {
|
|
109
|
+
const tempFile = path.join(process.cwd(), 'test-temp-empty.jsonl');
|
|
110
|
+
const parsed = parseJsonl(tempFile);
|
|
111
|
+
expect(parsed).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable event queue for webhook event processing
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { getConfig } from '../config/index.js';
|
|
8
|
+
import { nowIso } from '../util/formatters.js';
|
|
9
|
+
import { logEvent } from './eventLog.js';
|
|
10
|
+
/**
|
|
11
|
+
* Ensure directory exists
|
|
12
|
+
*/
|
|
13
|
+
function ensureDir(dirPath) {
|
|
14
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Append a JSON object as a line to the general event log (backward compatibility)
|
|
18
|
+
* This is separate from the Event Gateway event log
|
|
19
|
+
*/
|
|
20
|
+
export function appendEvent(event) {
|
|
21
|
+
const { eventsLog } = getConfig();
|
|
22
|
+
ensureDir(path.dirname(eventsLog));
|
|
23
|
+
const line = JSON.stringify({ at: nowIso(), ...event }) + '\n';
|
|
24
|
+
fs.appendFileSync(eventsLog, line, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Append a queue entry to the durable queue
|
|
28
|
+
*/
|
|
29
|
+
export function enqueue(event, cmd, body, timeoutMs) {
|
|
30
|
+
const { eventQueuePath } = getConfig();
|
|
31
|
+
ensureDir(path.dirname(eventQueuePath));
|
|
32
|
+
const entry = {
|
|
33
|
+
ts: nowIso(),
|
|
34
|
+
event,
|
|
35
|
+
cmd,
|
|
36
|
+
body,
|
|
37
|
+
timeoutMs,
|
|
38
|
+
};
|
|
39
|
+
const line = JSON.stringify(entry) + '\n';
|
|
40
|
+
fs.appendFileSync(eventQueuePath, line, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read cursor position (byte offset of last processed entry)
|
|
44
|
+
*/
|
|
45
|
+
function readCursor() {
|
|
46
|
+
const { eventQueueCursorPath } = getConfig();
|
|
47
|
+
if (!fs.existsSync(eventQueueCursorPath))
|
|
48
|
+
return 0;
|
|
49
|
+
const content = fs.readFileSync(eventQueueCursorPath, 'utf8').trim();
|
|
50
|
+
return parseInt(content, 10) || 0;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Write cursor position
|
|
54
|
+
*/
|
|
55
|
+
function writeCursor(position) {
|
|
56
|
+
const { eventQueueCursorPath } = getConfig();
|
|
57
|
+
ensureDir(path.dirname(eventQueueCursorPath));
|
|
58
|
+
fs.writeFileSync(eventQueueCursorPath, position.toString(), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse JSONL entries from file starting at cursor position
|
|
62
|
+
*/
|
|
63
|
+
function readEntriesFromCursor() {
|
|
64
|
+
const { eventQueuePath } = getConfig();
|
|
65
|
+
if (!fs.existsSync(eventQueuePath)) {
|
|
66
|
+
return { entries: [], newPosition: 0 };
|
|
67
|
+
}
|
|
68
|
+
const cursor = readCursor();
|
|
69
|
+
const buf = fs.readFileSync(eventQueuePath);
|
|
70
|
+
// Read from cursor position (byte-based to match writeCursor)
|
|
71
|
+
const remaining = buf.subarray(cursor).toString('utf8');
|
|
72
|
+
const lines = remaining
|
|
73
|
+
.split('\n')
|
|
74
|
+
.filter((line) => line.trim())
|
|
75
|
+
.map((line) => JSON.parse(line));
|
|
76
|
+
const newPosition = buf.length;
|
|
77
|
+
return { entries: lines, newPosition };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Execute a queue entry (spawn command with body piped to stdin)
|
|
81
|
+
*/
|
|
82
|
+
async function executeEntry(entry) {
|
|
83
|
+
const startTime = Date.now();
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
// Parse command and args
|
|
86
|
+
const parts = entry.cmd.split(/\s+/);
|
|
87
|
+
const command = parts[0];
|
|
88
|
+
const args = parts.slice(1);
|
|
89
|
+
const proc = spawn(command, args, {
|
|
90
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
91
|
+
shell: true,
|
|
92
|
+
});
|
|
93
|
+
// Pipe body as JSON to stdin
|
|
94
|
+
const bodyJson = JSON.stringify(entry.body);
|
|
95
|
+
proc.stdin.write(bodyJson);
|
|
96
|
+
proc.stdin.end();
|
|
97
|
+
// Setup timeout
|
|
98
|
+
const timeout = setTimeout(() => {
|
|
99
|
+
proc.kill('SIGTERM');
|
|
100
|
+
}, entry.timeoutMs);
|
|
101
|
+
proc.on('exit', (code) => {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
const exitCode = code ?? -1;
|
|
104
|
+
const durationMs = Date.now() - startTime;
|
|
105
|
+
resolve({ exitCode, durationMs });
|
|
106
|
+
});
|
|
107
|
+
proc.on('error', () => {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
const durationMs = Date.now() - startTime;
|
|
110
|
+
resolve({ exitCode: -1, durationMs });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Process one batch of queue entries
|
|
116
|
+
*/
|
|
117
|
+
async function processBatch() {
|
|
118
|
+
const { entries, newPosition } = readEntriesFromCursor();
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
try {
|
|
121
|
+
const { exitCode, durationMs } = await executeEntry(entry);
|
|
122
|
+
// Log to event log
|
|
123
|
+
logEvent({
|
|
124
|
+
event: entry.event,
|
|
125
|
+
matched: true,
|
|
126
|
+
exitCode,
|
|
127
|
+
durationMs,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Log error but continue (errors are ignored per spec)
|
|
132
|
+
logEvent({
|
|
133
|
+
event: entry.event,
|
|
134
|
+
matched: true,
|
|
135
|
+
exitCode: -1,
|
|
136
|
+
durationMs: 0,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Update cursor
|
|
141
|
+
if (entries.length > 0) {
|
|
142
|
+
writeCursor(newPosition);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Start the queue drain loop
|
|
147
|
+
*/
|
|
148
|
+
export function startQueueProcessor() {
|
|
149
|
+
setInterval(() => {
|
|
150
|
+
void processBatch();
|
|
151
|
+
}, 5000);
|
|
152
|
+
// Process immediately on start
|
|
153
|
+
void processBatch();
|
|
154
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event queue service tests
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { nowIso } from '../util/formatters.js';
|
|
8
|
+
describe('eventQueue', () => {
|
|
9
|
+
it('should format queue entries correctly', () => {
|
|
10
|
+
const entry = {
|
|
11
|
+
ts: nowIso(),
|
|
12
|
+
event: 'test-event',
|
|
13
|
+
cmd: 'node test.js',
|
|
14
|
+
body: { foo: 'bar', baz: 123 },
|
|
15
|
+
timeoutMs: 30000,
|
|
16
|
+
};
|
|
17
|
+
const json = JSON.stringify(entry);
|
|
18
|
+
const parsed = JSON.parse(json);
|
|
19
|
+
expect(parsed.event).toBe('test-event');
|
|
20
|
+
expect(parsed.cmd).toBe('node test.js');
|
|
21
|
+
expect(parsed.body).toEqual({ foo: 'bar', baz: 123 });
|
|
22
|
+
expect(parsed.timeoutMs).toBe(30000);
|
|
23
|
+
expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
24
|
+
});
|
|
25
|
+
it('should write entries as JSONL', () => {
|
|
26
|
+
const entry1 = {
|
|
27
|
+
ts: nowIso(),
|
|
28
|
+
event: 'event1',
|
|
29
|
+
cmd: 'cmd1',
|
|
30
|
+
body: { a: 1 },
|
|
31
|
+
timeoutMs: 10000,
|
|
32
|
+
};
|
|
33
|
+
const entry2 = {
|
|
34
|
+
ts: nowIso(),
|
|
35
|
+
event: 'event2',
|
|
36
|
+
cmd: 'cmd2',
|
|
37
|
+
body: { b: 2 },
|
|
38
|
+
timeoutMs: 20000,
|
|
39
|
+
};
|
|
40
|
+
const tempFile = path.join(process.cwd(), 'test-queue.jsonl');
|
|
41
|
+
const lines = [entry1, entry2].map((e) => JSON.stringify(e)).join('\n');
|
|
42
|
+
fs.writeFileSync(tempFile, lines + '\n', 'utf8');
|
|
43
|
+
const content = fs.readFileSync(tempFile, 'utf8');
|
|
44
|
+
const parsed = content
|
|
45
|
+
.split('\n')
|
|
46
|
+
.filter((line) => line.trim())
|
|
47
|
+
.map((line) => JSON.parse(line));
|
|
48
|
+
expect(parsed).toHaveLength(2);
|
|
49
|
+
expect(parsed[0].event).toBe('event1');
|
|
50
|
+
expect(parsed[1].event).toBe('event2');
|
|
51
|
+
// Cleanup
|
|
52
|
+
fs.unlinkSync(tempFile);
|
|
53
|
+
});
|
|
54
|
+
it('should handle cursor-based reading', () => {
|
|
55
|
+
const entry1 = JSON.stringify({
|
|
56
|
+
ts: nowIso(),
|
|
57
|
+
event: 'e1',
|
|
58
|
+
cmd: 'c1',
|
|
59
|
+
body: {},
|
|
60
|
+
timeoutMs: 1000,
|
|
61
|
+
});
|
|
62
|
+
const entry2 = JSON.stringify({
|
|
63
|
+
ts: nowIso(),
|
|
64
|
+
event: 'e2',
|
|
65
|
+
cmd: 'c2',
|
|
66
|
+
body: {},
|
|
67
|
+
timeoutMs: 2000,
|
|
68
|
+
});
|
|
69
|
+
const tempFile = path.join(process.cwd(), 'test-cursor-queue.jsonl');
|
|
70
|
+
fs.writeFileSync(tempFile, entry1 + '\n' + entry2 + '\n', 'utf8');
|
|
71
|
+
const fullContent = fs.readFileSync(tempFile, 'utf8');
|
|
72
|
+
const cursorPosition = Buffer.byteLength(entry1 + '\n', 'utf8');
|
|
73
|
+
// Read from cursor
|
|
74
|
+
const remaining = fullContent.slice(cursorPosition);
|
|
75
|
+
const lines = remaining
|
|
76
|
+
.split('\n')
|
|
77
|
+
.filter((line) => line.trim())
|
|
78
|
+
.map((line) => JSON.parse(line));
|
|
79
|
+
expect(lines).toHaveLength(1);
|
|
80
|
+
expect(lines[0].event).toBe('e2');
|
|
81
|
+
// Cleanup
|
|
82
|
+
fs.unlinkSync(tempFile);
|
|
83
|
+
});
|
|
84
|
+
it('should preserve body structure through JSON serialization', () => {
|
|
85
|
+
const complexBody = {
|
|
86
|
+
string: 'test',
|
|
87
|
+
number: 123,
|
|
88
|
+
boolean: true,
|
|
89
|
+
null: null,
|
|
90
|
+
array: [1, 2, 3],
|
|
91
|
+
nested: { a: { b: { c: 'deep' } } },
|
|
92
|
+
};
|
|
93
|
+
const entry = {
|
|
94
|
+
ts: nowIso(),
|
|
95
|
+
event: 'complex',
|
|
96
|
+
cmd: 'node test.js',
|
|
97
|
+
body: complexBody,
|
|
98
|
+
timeoutMs: 5000,
|
|
99
|
+
};
|
|
100
|
+
const json = JSON.stringify(entry);
|
|
101
|
+
const parsed = JSON.parse(json);
|
|
102
|
+
expect(parsed.body).toEqual(complexBody);
|
|
103
|
+
});
|
|
104
|
+
});
|