@tuongaz/seeflow 0.1.42 → 0.1.47

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.
@@ -12,7 +12,7 @@
12
12
  "kind": "request"
13
13
  },
14
14
  "description": "Creates order, kicks off the pipeline.",
15
- "detail": "file://nodes/node-XKIyds0TDg/detail.md",
15
+ "detail": "file://detail.md",
16
16
  "playAction": {
17
17
  "kind": "script",
18
18
  "interpreter": "bun",
@@ -33,7 +33,7 @@
33
33
  "kind": "event"
34
34
  },
35
35
  "description": "Reserves stock.",
36
- "detail": "file://nodes/node-GXTKUcE3ye/detail.md",
36
+ "detail": "file://detail.md",
37
37
  "icon": "a-arrow-down-icon"
38
38
  }
39
39
  },
@@ -47,7 +47,7 @@
47
47
  "kind": "event"
48
48
  },
49
49
  "description": "Charges card.",
50
- "detail": "file://nodes/node-YOYiHJpY0i/detail.md"
50
+ "detail": "file://detail.md"
51
51
  }
52
52
  },
53
53
  {
@@ -60,7 +60,7 @@
60
60
  "kind": "event"
61
61
  },
62
62
  "description": "Enqueues shipment.",
63
- "detail": "file://nodes/node-zUIH7WFnhK/detail.md"
63
+ "detail": "file://detail.md"
64
64
  }
65
65
  }
66
66
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuongaz/seeflow",
3
- "version": "0.1.42",
3
+ "version": "0.1.47",
4
4
  "description": "Local studio that hosts file-defined demos as React Flow canvases wired to a running app via REST + SSE + Zod schema.",
5
5
  "keywords": [
6
6
  "seeflow",
@@ -49,6 +49,7 @@
49
49
  "zod-to-json-schema": "^3.25.2"
50
50
  },
51
51
  "devDependencies": {
52
+ "@playwright/test": "^1.60.0",
52
53
  "@tailwindcss/browser": "^4.3.0",
53
54
  "@types/bun": "^1.1.14",
54
55
  "typescript": "^5.6.3"
package/src/cli-e2e.ts CHANGED
@@ -63,7 +63,7 @@ interface NodeShape {
63
63
  };
64
64
  }
65
65
 
66
- interface DemoShape {
66
+ interface FlowBody {
67
67
  nodes?: NodeShape[];
68
68
  }
69
69
 
@@ -71,7 +71,11 @@ interface FlowGetResponse {
71
71
  id?: string;
72
72
  valid?: boolean;
73
73
  error?: string | null;
74
- demo?: DemoShape | null;
74
+ // GET /api/flows/:id returns the resolved flow under the `flow` key (see
75
+ // FlowGetResponse in operations.ts). Older versions of this file used `demo`,
76
+ // which left the validator effectively broken (every call returned `ok:false`
77
+ // with "demo not valid"). Renamed to match the wire format.
78
+ flow?: FlowBody | null;
75
79
  }
76
80
 
77
81
  interface SseEvent {
@@ -200,21 +204,21 @@ export async function validateEndToEnd(options: ValidateOptions): Promise<Valida
200
204
  };
201
205
  }
202
206
  const demoData = (await demoRes.json()) as FlowGetResponse;
203
- if (!demoData.valid || !demoData.demo) {
207
+ if (!demoData.valid || !demoData.flow) {
204
208
  return {
205
209
  ok: false,
206
210
  plays,
207
211
  statuses,
208
212
  skipped: [
209
213
  {
210
- nodeId: '<demo>',
211
- reason: `demo not valid: ${demoData.error ?? '<no error>'}`,
214
+ nodeId: '<flow>',
215
+ reason: `flow not valid: ${demoData.error ?? '<no error>'}`,
212
216
  },
213
217
  ],
214
218
  };
215
219
  }
216
220
 
217
- const nodes = demoData.demo.nodes ?? [];
221
+ const nodes = demoData.flow.nodes ?? [];
218
222
 
219
223
  const skipSet = new Set(options.skipNodes ?? []);
220
224
  const playTargets: string[] = [];
package/src/file-ref.ts CHANGED
@@ -14,13 +14,18 @@ const isCleanRelativePath = (p: string): boolean => {
14
14
  const invalidMarker = (rawPath: string) => `[seeflow: invalid file:// path '${rawPath}']`;
15
15
  const missingMarker = (rawPath: string) => `[seeflow: missing file '${rawPath}']`;
16
16
 
17
+ const looksLikeFlowNode = (obj: Record<string, unknown>): obj is { id: string; data: object } =>
18
+ typeof obj.id === 'string' && obj.data !== null && typeof obj.data === 'object';
19
+
17
20
  /**
18
21
  * Resolve every `file://<relative-path>` string in `raw` by reading the file
19
- * under `<seeflowRoot>` and substituting its UTF-8 content. Missing or invalid
20
- * paths are replaced with placeholder markers so schema parse still succeeds.
22
+ * under `<seeflowRoot>/nodes/<nodeId>/` (node-relative) and substituting its
23
+ * UTF-8 content. Strings outside any enclosing flow node are treated as
24
+ * invalid — every supported file:// ref currently lives inside `node.data`.
21
25
  *
22
- * Returns the mutated tree plus the sorted, de-duplicated list of relative
23
- * paths that resolved cleanly (the watcher tracks these for live reload).
26
+ * Returns the mutated tree plus the sorted, de-duplicated list of seeflow-root-relative
27
+ * paths that resolved cleanly (the watcher tracks these for live reload, so the
28
+ * external contract uses `nodes/<id>/<file>` even though the source string is short).
24
29
  */
25
30
  export function resolveFileRefs(
26
31
  raw: unknown,
@@ -34,46 +39,52 @@ export function resolveFileRefs(
34
39
  seeflowRealRoot = seeflowRoot;
35
40
  }
36
41
 
37
- const resolveString = (s: string): string => {
42
+ const resolveString = (s: string, nodeId: string | null): string => {
38
43
  if (!s.startsWith(FILE_PREFIX)) return s;
39
44
  const relPath = s.slice(FILE_PREFIX.length);
40
45
  if (!isCleanRelativePath(relPath)) return invalidMarker(relPath);
46
+ if (nodeId === null) return invalidMarker(relPath);
41
47
 
42
- const abs = join(seeflowRoot, relPath);
43
- if (!existsSync(abs)) return missingMarker(relPath);
48
+ const seeflowRelPath = `nodes/${nodeId}/${relPath}`;
49
+ const abs = join(seeflowRoot, seeflowRelPath);
50
+ if (!existsSync(abs)) return missingMarker(seeflowRelPath);
44
51
 
45
52
  // Symlink-escape defense: resolve realpath and confirm it stays inside root.
46
53
  let realAbs: string;
47
54
  try {
48
55
  realAbs = realpathSync(abs);
49
56
  } catch {
50
- return missingMarker(relPath);
57
+ return missingMarker(seeflowRelPath);
51
58
  }
52
59
  const rel = relative(seeflowRealRoot, realAbs);
53
60
  if (rel.startsWith('..') || isAbsolute(rel)) return invalidMarker(relPath);
54
61
 
55
62
  try {
56
63
  const content = readFileSync(realAbs, 'utf8');
57
- refs.add(relPath);
64
+ refs.add(seeflowRelPath);
58
65
  return content;
59
66
  } catch {
60
- return missingMarker(relPath);
67
+ return missingMarker(seeflowRelPath);
61
68
  }
62
69
  };
63
70
 
64
- const walk = (node: unknown): unknown => {
65
- if (typeof node === 'string') return resolveString(node);
66
- if (Array.isArray(node)) return node.map(walk);
71
+ const walk = (node: unknown, nodeId: string | null): unknown => {
72
+ if (typeof node === 'string') return resolveString(node, nodeId);
73
+ if (Array.isArray(node)) return node.map((v) => walk(v, nodeId));
67
74
  if (node && typeof node === 'object') {
75
+ const obj = node as Record<string, unknown>;
76
+ // Entering a flow node carves out a new resolution context for its subtree:
77
+ // any file:// inside `data` now resolves relative to nodes/<id>/.
78
+ const childNodeId = looksLikeFlowNode(obj) ? obj.id : nodeId;
68
79
  const out: Record<string, unknown> = {};
69
- for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
70
- out[k] = walk(v);
80
+ for (const [k, v] of Object.entries(obj)) {
81
+ out[k] = walk(v, childNodeId);
71
82
  }
72
83
  return out;
73
84
  }
74
85
  return node;
75
86
  };
76
87
 
77
- const resolved = walk(raw);
88
+ const resolved = walk(raw, null);
78
89
  return { resolved, refs: [...refs].sort() };
79
90
  }
package/src/node-files.ts CHANGED
@@ -29,8 +29,11 @@ export type ExternalizedFieldName = (typeof EXTERNALIZED_NODE_FIELDS)[number]['f
29
29
  export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
30
30
  `nodes/${nodeId}/${fileName}`;
31
31
 
32
- export const nodeFileRef = (nodeId: string, fileName: string): string =>
33
- `file://${nodeFileRelPath(nodeId, fileName)}`;
32
+ // Node-relative ref: the resolver knows the enclosing node id from the flow.json
33
+ // shape (nodes[i].id), so the on-disk string only needs the filename. Kept as a
34
+ // 2-arg helper so call sites don't change shape and the spec stays explicit
35
+ // that the file lives under the given node.
36
+ export const nodeFileRef = (_nodeId: string, fileName: string): string => `file://${fileName}`;
34
37
 
35
38
  export const nodeFileAbsPath = (repoPath: string, nodeId: string, fileName: string): string =>
36
39
  join(repoPath, '.seeflow', nodeFileRelPath(nodeId, fileName));
package/src/operations.ts CHANGED
@@ -658,13 +658,27 @@ export async function mutateMergedFlow<E extends { kind: string }>(
658
658
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
659
659
  }
660
660
 
661
- const snap: FlowSnapshot = {
662
- flow: finalParse.data as ResolvedFlow,
663
- valid: true,
664
- error: null,
665
- filePath: flowPath,
666
- parsedAt: Date.now(),
667
- };
661
+ // Re-read through readMergedFlow so the snapshot we hand to notifyWritten
662
+ // carries file://-resolved content (detail.md, view.html, …). The in-memory
663
+ // `merged` tree above still holds raw `file://<name>` strings — broadcasting
664
+ // it would clobber the watcher's resolved seed and ship unresolved refs to
665
+ // every SSE subscriber until the next reparse.
666
+ const reread = readMergedFlow(flowPath);
667
+ const snap: FlowSnapshot = reread.valid
668
+ ? {
669
+ flow: reread.flow,
670
+ valid: true,
671
+ error: null,
672
+ filePath: flowPath,
673
+ parsedAt: Date.now(),
674
+ }
675
+ : {
676
+ flow: finalParse.data as ResolvedFlow,
677
+ valid: true,
678
+ error: null,
679
+ filePath: flowPath,
680
+ parsedAt: Date.now(),
681
+ };
668
682
  return { kind: 'ok', snap, flowContent, styleContent };
669
683
  }
670
684