@xdelivered/emberflow 0.4.0 → 0.5.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.
Files changed (37) hide show
  1. package/dist/server/index.js +23 -2
  2. package/dist/server/nodesPayload.d.ts +14 -1
  3. package/dist/server/nodesPayload.js +15 -2
  4. package/dist/server/projectMode.js +5 -2
  5. package/dist/server/sourceNav.d.ts +77 -0
  6. package/dist/server/sourceNav.js +503 -0
  7. package/dist/src/engine/registry.d.ts +27 -2
  8. package/dist/src/engine/registry.js +84 -2
  9. package/dist/src/nodes/index.d.ts +8 -2
  10. package/dist/src/nodes/index.js +7 -3
  11. package/dist/src/nodes/login.d.ts +3 -1
  12. package/dist/src/nodes/login.js +2 -2
  13. package/package.json +2 -2
  14. package/server/index.ts +24 -2
  15. package/server/nodesPayload.test.ts +38 -0
  16. package/server/nodesPayload.ts +28 -3
  17. package/server/projectMode.ts +5 -2
  18. package/server/sourceNav.test.ts +382 -0
  19. package/server/sourceNav.ts +644 -0
  20. package/server/sourceNavRoute.test.ts +163 -0
  21. package/src/components/Inspector.tsx +9 -26
  22. package/src/components/SourceNavigator.test.tsx +226 -0
  23. package/src/components/SourceNavigator.tsx +520 -0
  24. package/src/engine/registry.sourceRef.test.ts +112 -0
  25. package/src/engine/registry.ts +103 -2
  26. package/src/nodes/index.ts +10 -3
  27. package/src/nodes/login.ts +5 -2
  28. package/src/store/builderStore.ts +2 -2
  29. package/src/store/nodeMeta.ts +10 -2
  30. package/src/store/sourceNavClient.ts +48 -0
  31. package/studio-dist/assets/{index-CGwEx82J.js → index-BbzppDpt.js} +25 -25
  32. package/studio-dist/assets/{index-CAIDjNhv.css → index-CLf6blcA.css} +1 -1
  33. package/studio-dist/index.html +2 -2
  34. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  35. package/templates/skills/emberflow-model-process/SKILL.md +11 -1
  36. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  37. package/templates/skills/emberflow-review-workflow/SKILL.md +28 -1
@@ -0,0 +1,163 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ // End-to-end coverage of GET /source-file plus the nodesPayload sourceRef
8
+ // wiring, through the real runner subprocess (same harness as
9
+ // diagnosticsRoute.test.ts). Uses the regression fixture shape:
10
+ // DeriveShipmentActuals registered from nodes.mjs, whose handler imports
11
+ // deriveTrackingActual from shipment-logic.mjs.
12
+
13
+ let proc: ChildProcess;
14
+ const PORT = 8157;
15
+ const base = `http://127.0.0.1:${PORT}`;
16
+ let projectDir: string;
17
+
18
+ async function waitHealthy(url: string, tries = 40): Promise<void> {
19
+ for (let i = 0; i < tries; i++) {
20
+ try {
21
+ if ((await fetch(url)).ok) return;
22
+ } catch {
23
+ /* not up yet */
24
+ }
25
+ await new Promise((r) => setTimeout(r, 150));
26
+ }
27
+ throw new Error('runner did not become healthy');
28
+ }
29
+
30
+ function sourceFileUrl(path: string): string {
31
+ return `${base}/api/source-file?${new URLSearchParams({ path })}`;
32
+ }
33
+
34
+ beforeAll(async () => {
35
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenavroute-')));
36
+ const w = (rel: string, content: string): void => writeFileSync(join(projectDir, rel), content);
37
+ w(
38
+ 'shipment-logic.mjs',
39
+ [
40
+ 'function nestedHelper(shipment) {', // 1
41
+ ' return (shipment.weight ?? 0) * 2;', // 2
42
+ '}', // 3
43
+ '', // 4
44
+ 'export function deriveTrackingActual(shipment) {', // 5
45
+ ' return nestedHelper(shipment);', // 6
46
+ '}', // 7
47
+ '',
48
+ ].join('\n'),
49
+ );
50
+ w(
51
+ 'nodes.mjs',
52
+ [
53
+ "import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
54
+ '', // 2
55
+ 'export function registerNodes(registry) {', // 3
56
+ ' registry.register(', // 4
57
+ " { type: 'DeriveShipmentActuals', label: 'Derive Shipment Actuals' },", // 5
58
+ ' async (ctx) => ({ actual: deriveTrackingActual(ctx.input) }),', // 6
59
+ ' );', // 7
60
+ '}', // 8
61
+ '',
62
+ ].join('\n'),
63
+ );
64
+ w(
65
+ 'emberflow.config.mjs',
66
+ ["import { registerNodes } from './nodes.mjs';", 'export default { registerNodes };', ''].join(
67
+ '\n',
68
+ ),
69
+ );
70
+ w('.env', 'TOP_SECRET=shh\n');
71
+
72
+ proc = spawn('npx', ['tsx', 'server/index.ts'], {
73
+ env: {
74
+ ...process.env,
75
+ EMBERFLOW_RUNNER_PORT: String(PORT),
76
+ EMBERFLOW_PROJECT: projectDir,
77
+ },
78
+ stdio: 'ignore',
79
+ });
80
+ await waitHealthy(`${base}/healthz`);
81
+ }, 20_000);
82
+
83
+ afterAll(() => {
84
+ proc?.kill();
85
+ if (projectDir) rmSync(projectDir, { recursive: true, force: true });
86
+ });
87
+
88
+ describe('GET /nodes sourceRef wiring', () => {
89
+ it('carries a repo-relative sourceRef for the project node and builtin flag for package nodes', async () => {
90
+ const res = await fetch(`${base}/api/nodes`);
91
+ expect(res.status).toBe(200);
92
+ const body = (await res.json()) as {
93
+ nodes: Array<{ type: string; sourceRef?: { file: string; line?: number }; builtin?: boolean }>;
94
+ };
95
+ const project = body.nodes.find((n) => n.type === 'DeriveShipmentActuals');
96
+ expect(project).toBeDefined();
97
+ expect(project!.sourceRef).toEqual({ file: 'nodes.mjs', line: 4 });
98
+ expect(project!.builtin).toBeUndefined();
99
+
100
+ const builtin = body.nodes.find((n) => n.type === 'ValidateCredentials');
101
+ expect(builtin).toBeDefined();
102
+ expect(builtin!.builtin).toBe(true);
103
+ expect(builtin!.sourceRef).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('GET /source-file', () => {
108
+ it('serves the handler module with the imported helper resolved to its project file', async () => {
109
+ const res = await fetch(sourceFileUrl('nodes.mjs'));
110
+ expect(res.status).toBe(200);
111
+ const body = (await res.json()) as {
112
+ path: string;
113
+ content: string;
114
+ language: string;
115
+ symbols: {
116
+ imports: Array<{ local: string; resolution: { kind: string; path?: string; line?: number } }>;
117
+ };
118
+ };
119
+ expect(body.path).toBe('nodes.mjs');
120
+ expect(body.language).toBe('js');
121
+ expect(body.content).toContain('DeriveShipmentActuals');
122
+ const imp = body.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
123
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
124
+ });
125
+
126
+ it('exposes declaration lines on the helper module', async () => {
127
+ const res = await fetch(sourceFileUrl('shipment-logic.mjs'));
128
+ expect(res.status).toBe(200);
129
+ const body = (await res.json()) as {
130
+ symbols: {
131
+ declarations: Array<{ name: string; line: number; endLine: number; exported: boolean }>;
132
+ };
133
+ };
134
+ const derive = body.symbols.declarations.find((d) => d.name === 'deriveTrackingActual');
135
+ expect(derive).toMatchObject({ line: 5, endLine: 7, exported: true });
136
+ });
137
+
138
+ it('400s on a traversal path without echoing it', async () => {
139
+ const res = await fetch(sourceFileUrl('../../etc/passwd'));
140
+ expect(res.status).toBe(400);
141
+ const body = (await res.json()) as { error: string };
142
+ expect(body.error).not.toContain('passwd');
143
+ });
144
+
145
+ it('400s on secret basenames and node_modules paths', async () => {
146
+ for (const p of ['.env', 'node_modules/x/index.js']) {
147
+ const res = await fetch(sourceFileUrl(p));
148
+ expect(res.status).toBe(400);
149
+ const body = (await res.json()) as { error: string };
150
+ expect(body.error).not.toContain(p);
151
+ }
152
+ });
153
+
154
+ it('400s when the path param is missing', async () => {
155
+ const res = await fetch(`${base}/api/source-file`);
156
+ expect(res.status).toBe(400);
157
+ });
158
+
159
+ it('404s for a missing file inside the project', async () => {
160
+ const res = await fetch(sourceFileUrl('missing.mjs'));
161
+ expect(res.status).toBe(404);
162
+ });
163
+ });
@@ -4,8 +4,7 @@ import { BranchRulesEditor } from './BranchRulesEditor';
4
4
  import { ExecutionPager } from './ExecutionPager';
5
5
  import { NodeRunModal } from './NodeRunModal';
6
6
  import { Json } from './Json';
7
- import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
8
- import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
7
+ import { NodeCodeView } from './SourceNavigator';
9
8
  import { Badge } from '@/components/ui/badge';
10
9
  import { Button } from '@/components/ui/button';
11
10
  import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
@@ -781,6 +780,8 @@ export function Inspector() {
781
780
  const outputValue = state?.output as Record<string, unknown> | undefined;
782
781
  const executions = state?.executions;
783
782
  const source = registry.has(node.type) ? (registry.getSource(node.type) ?? '') : '';
783
+ const sourceRef = registry.has(node.type) ? registry.getSourceRef(node.type) : undefined;
784
+ const builtinNode = registry.has(node.type) ? registry.isBuiltin(node.type) : false;
784
785
 
785
786
  const codeDialog = (
786
787
  <Dialog open={codeOpen} onOpenChange={setCodeOpen}>
@@ -790,30 +791,12 @@ export function Inspector() {
790
791
  <Badge variant="mono">{node.type}</Badge>
791
792
  </DialogTitle>
792
793
  <DialogDescription>{definition?.description ?? 'Node implementation'}</DialogDescription>
793
- <div className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-background">
794
- <SyntaxHighlighter
795
- language="javascript"
796
- style={vscDarkPlus}
797
- showLineNumbers
798
- customStyle={{
799
- margin: 0,
800
- background: 'transparent',
801
- fontSize: '12.5px',
802
- lineHeight: 1.6,
803
- padding: '12px 2px',
804
- }}
805
- codeTagProps={{ style: { background: 'transparent', textShadow: 'none' } }}
806
- lineNumberStyle={{
807
- color: 'var(--muted-foreground)',
808
- opacity: 0.35,
809
- background: 'transparent',
810
- minWidth: '2.75em',
811
- paddingRight: '1em',
812
- }}
813
- >
814
- {source}
815
- </SyntaxHighlighter>
816
- </div>
794
+ <NodeCodeView
795
+ nodeType={node.type}
796
+ source={source}
797
+ sourceRef={sourceRef}
798
+ builtin={builtinNode}
799
+ />
817
800
  </DialogContent>
818
801
  </Dialog>
819
802
  );
@@ -0,0 +1,226 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { NodeCodeView, SourceNavigator } from './SourceNavigator';
4
+ import type { SourceFileFetchResult, SourceFilePayload } from '../store/sourceNavClient';
5
+
6
+ // Props-driven fixtures: renderToStaticMarkup never runs effects, so the
7
+ // fetch cache is pre-populated via initialFiles and the fetcher is inert.
8
+ const neverFetch = () => new Promise<SourceFileFetchResult>(() => {});
9
+
10
+ const NODES_MJS: SourceFilePayload = {
11
+ path: 'nodes.mjs',
12
+ content: [
13
+ "import { deriveTrackingActual } from './shipment-logic.mjs';",
14
+ "import express from 'express';",
15
+ "import { createHash } from 'node:crypto';",
16
+ 'const RATE = 1.2;',
17
+ 'export function handler(input) {',
18
+ ' return deriveTrackingActual(input) * RATE;',
19
+ '}',
20
+ ].join('\n'),
21
+ language: 'js',
22
+ symbols: {
23
+ declarations: [
24
+ { name: 'RATE', kind: 'const', line: 4, endLine: 4, exported: false },
25
+ { name: 'handler', kind: 'fn', line: 5, endLine: 7, exported: true },
26
+ ],
27
+ imports: [
28
+ {
29
+ name: 'deriveTrackingActual',
30
+ local: 'deriveTrackingActual',
31
+ from: './shipment-logic.mjs',
32
+ resolution: { kind: 'project', path: 'shipment-logic.mjs', line: 5 },
33
+ },
34
+ {
35
+ name: 'default',
36
+ local: 'express',
37
+ from: 'express',
38
+ resolution: { kind: 'external', package: 'express' },
39
+ },
40
+ {
41
+ name: 'createHash',
42
+ local: 'createHash',
43
+ from: 'node:crypto',
44
+ resolution: { kind: 'builtin' },
45
+ },
46
+ {
47
+ name: 'import()',
48
+ local: '',
49
+ from: 'x',
50
+ resolution: { kind: 'unresolved', reason: 'dynamic import target is computed' },
51
+ },
52
+ {
53
+ name: '*',
54
+ local: '',
55
+ from: './polyfill.mjs',
56
+ resolution: { kind: 'project', path: 'polyfill.mjs' },
57
+ },
58
+ ],
59
+ reexports: [],
60
+ },
61
+ };
62
+
63
+ const SHIPMENT_MJS: SourceFilePayload = {
64
+ path: 'shipment-logic.mjs',
65
+ content: 'export function deriveTrackingActual(input) { return input.actual; }',
66
+ language: 'js',
67
+ symbols: {
68
+ declarations: [
69
+ { name: 'deriveTrackingActual', kind: 'fn', line: 1, endLine: 1, exported: true },
70
+ ],
71
+ imports: [],
72
+ reexports: [],
73
+ },
74
+ };
75
+
76
+ const files = (payload: SourceFilePayload): Record<string, SourceFileFetchResult> => ({
77
+ [payload.path]: { ok: true, payload },
78
+ });
79
+
80
+ /** Highlighting splits code across token spans; strip tags to assert on the raw text. */
81
+ const text = (html: string): string => html.replace(/<[^>]+>/g, '');
82
+
83
+ describe('SourceNavigator', () => {
84
+ it('renders the file content with local declarations linkified for same-file navigation', () => {
85
+ const out = renderToStaticMarkup(
86
+ <SourceNavigator
87
+ entryFile="nodes.mjs"
88
+ entryLine={5}
89
+ nodeType="DeriveShipmentActuals"
90
+ fetcher={neverFetch}
91
+ initialFiles={files(NODES_MJS)}
92
+ />,
93
+ );
94
+ expect(out).toContain('deriveTrackingActual');
95
+ expect(out).toContain('RATE');
96
+ // Declaration + import identifiers render as link buttons (token linking).
97
+ expect(out).toContain('source-nav-link');
98
+ expect(out).toContain('Jump to line 5'); // handler declaration
99
+ expect(out).toContain('Open shipment-logic.mjs:5'); // project import token
100
+ });
101
+
102
+ it('renders a project import as a clickable path:line link in the Referenced code panel', () => {
103
+ const out = renderToStaticMarkup(
104
+ <SourceNavigator
105
+ entryFile="nodes.mjs"
106
+ nodeType="DeriveShipmentActuals"
107
+ fetcher={neverFetch}
108
+ initialFiles={files(NODES_MJS)}
109
+ />,
110
+ );
111
+ expect(out).toContain('Referenced code');
112
+ expect(out).toContain('shipment-logic.mjs:5');
113
+ expect(out).toContain('project');
114
+ });
115
+
116
+ it('badges external, builtin, and unresolved (with reason) references', () => {
117
+ const out = renderToStaticMarkup(
118
+ <SourceNavigator
119
+ entryFile="nodes.mjs"
120
+ nodeType="DeriveShipmentActuals"
121
+ fetcher={neverFetch}
122
+ initialFiles={files(NODES_MJS)}
123
+ />,
124
+ );
125
+ expect(out).toContain('package: express');
126
+ expect(out).toContain('Node.js builtin');
127
+ expect(out).toContain('unresolved — dynamic import target is computed');
128
+ // Imports without a local binding are still listed: dynamic + side-effect.
129
+ expect(out).toContain('import()');
130
+ expect(out).toContain('(side-effect import)');
131
+ });
132
+
133
+ it('shows the breadcrumb trail and a Back button at depth > 1', () => {
134
+ const out = renderToStaticMarkup(
135
+ <SourceNavigator
136
+ entryFile="nodes.mjs"
137
+ nodeType="DeriveShipmentActuals"
138
+ fetcher={neverFetch}
139
+ initialStack={[{ path: 'nodes.mjs', line: 5 }, { path: 'shipment-logic.mjs', line: 5 }]}
140
+ initialFiles={{ ...files(NODES_MJS), ...files(SHIPMENT_MJS) }}
141
+ />,
142
+ );
143
+ expect(out).toContain('nodes.mjs');
144
+ expect(out).toContain('shipment-logic.mjs');
145
+ expect(out).toContain('›');
146
+ expect(out).toContain('Back');
147
+ // Current file's content is on screen, not the ancestor's.
148
+ expect(text(out)).toContain('return input.actual;');
149
+ });
150
+
151
+ it('hides Back at depth 1', () => {
152
+ const out = renderToStaticMarkup(
153
+ <SourceNavigator
154
+ entryFile="nodes.mjs"
155
+ nodeType="DeriveShipmentActuals"
156
+ fetcher={neverFetch}
157
+ initialFiles={files(NODES_MJS)}
158
+ />,
159
+ );
160
+ expect(out).not.toContain('Back');
161
+ });
162
+
163
+ it('shows the resolver-unavailable banner while still rendering content', () => {
164
+ const degraded: SourceFilePayload = {
165
+ ...SHIPMENT_MJS,
166
+ resolver: 'unavailable',
167
+ symbols: { declarations: [], imports: [], reexports: [] },
168
+ };
169
+ const out = renderToStaticMarkup(
170
+ <SourceNavigator
171
+ entryFile="shipment-logic.mjs"
172
+ nodeType="DeriveShipmentActuals"
173
+ fetcher={neverFetch}
174
+ initialFiles={files(degraded)}
175
+ />,
176
+ );
177
+ expect(out).toContain('Symbol resolution unavailable (typescript not installed)');
178
+ expect(text(out)).toContain('return input.actual;');
179
+ });
180
+
181
+ it('renders the fetch error text (denied / missing / runner down) instead of code', () => {
182
+ const out = renderToStaticMarkup(
183
+ <SourceNavigator
184
+ entryFile=".env"
185
+ nodeType="DeriveShipmentActuals"
186
+ fetcher={neverFetch}
187
+ initialFiles={{ '.env': { ok: false, error: 'Access denied: path not servable' } }}
188
+ />,
189
+ );
190
+ expect(out).toContain('Access denied: path not servable');
191
+ expect(out).not.toContain('Referenced code');
192
+ });
193
+ });
194
+
195
+ describe('NodeCodeView (Inspector code dialog body)', () => {
196
+ it('without sourceRef renders exactly the legacy highlighter view — no Referenced code', () => {
197
+ const out = renderToStaticMarkup(
198
+ <NodeCodeView nodeType="Map" source="async (input) => input" fetcher={neverFetch} />,
199
+ );
200
+ expect(out).toContain('input');
201
+ expect(out).not.toContain('Referenced code');
202
+ expect(out).not.toContain('Built-in node');
203
+ expect(out).not.toContain('source-nav-link');
204
+ });
205
+
206
+ it('builtin nodes keep the legacy view plus a quiet Built-in node badge', () => {
207
+ const out = renderToStaticMarkup(
208
+ <NodeCodeView nodeType="Input" source="async () => ({})" builtin fetcher={neverFetch} />,
209
+ );
210
+ expect(out).toContain('Built-in node');
211
+ expect(out).not.toContain('Referenced code');
212
+ });
213
+
214
+ it('with a sourceRef renders the SourceNavigator (breadcrumb + loading state pre-fetch)', () => {
215
+ const out = renderToStaticMarkup(
216
+ <NodeCodeView
217
+ nodeType="DeriveShipmentActuals"
218
+ source="ignored"
219
+ sourceRef={{ file: 'nodes.mjs', line: 4 }}
220
+ fetcher={neverFetch}
221
+ />,
222
+ );
223
+ expect(out).toContain('nodes.mjs');
224
+ expect(out).toContain('Loading source…');
225
+ });
226
+ });