Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __tests__/flowRunner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ describe('FlowRunner', () => {
global.clearTimeout = originalGlobalClearTimeout;
});

describe('graceful degradation (unknown step types)', () => {
test('unknown step type is skipped with a warning, not thrown or stopped', async () => {
const step = { id: 'u1', name: 'Future Step', type: 'future_unknown_type' };
await runner._executeSingleStepLogic(step, {});
const last = runner.state.results[runner.state.results.length - 1];
expect(last.status).toBe('skipped');
expect(last.unsupported).toBe(true);
// the flow is NOT halted
expect(runner.state.stopRequested).toBe(false);
expect(mockOnError).not.toHaveBeenCalled();
// a warning was surfaced to the user
expect(mockOnMessage).toHaveBeenCalled();
});
});

describe('state management', () => {
it('should initialize with isRunning and isStepping false', () => {
expect(runner.isRunning()).toBe(false);
Expand Down
25 changes: 23 additions & 2 deletions __tests__/flowVisualizer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,17 @@ describe('FlowVisualizer (Drawflow)', () => {
await waitForVisualizerRender();

const drawflowId = visualizer.nodeIdByStepId.get('step1');
// A real Drawflow drag updates BOTH the data model (pos_x/pos_y) AND the
// node element's style.left/top in lock-step (see drawflow position()).
// The nodeMoved handler treats style.left/top as authoritative, so the
// test must move the DOM element too — mutating the data model alone is
// not how the interaction reaches the handler.
const nodeData = visualizer.editor.drawflow.drawflow.Home.data[drawflowId];
nodeData.pos_x = 300;
nodeData.pos_y = 400;
const nodeEl = container.querySelector(`#node-${drawflowId}`);
nodeEl.style.left = '300px';
nodeEl.style.top = '400px';

visualizer.editor.dispatch('nodeMoved', drawflowId);
expect(mockCallbacks.onNodeLayoutUpdate).toHaveBeenCalledWith('step1', 300, 400);
Expand Down Expand Up @@ -231,15 +239,23 @@ describe('FlowVisualizer (Drawflow)', () => {
mockCallbacks.onNodeLayoutUpdate = (id, x, y) => {
handleVisualizerNodeLayoutUpdate(id, x, y);
};
// Replace the beforeEach visualizer: destroy the old one first so its
// modal (appended to document.body) does not leak into later tests.
visualizer.destroy();
visualizer = new FlowVisualizer(container, mockCallbacks);

visualizer.render(mockFlow, null);
await waitForVisualizerRender();

const drawflowId = visualizer.nodeIdByStepId.get('step1');
// Mirror a real Drawflow drag: move both the data model and the DOM
// element, which the nodeMoved handler reads as authoritative.
const nodeData = visualizer.editor.drawflow.drawflow.Home.data[drawflowId];
nodeData.pos_x = 500;
nodeData.pos_y = 600;
const nodeEl = container.querySelector(`#node-${drawflowId}`);
nodeEl.style.left = '500px';
nodeEl.style.top = '600px';

visualizer.editor.dispatch('nodeMoved', drawflowId);

Expand All @@ -255,7 +271,10 @@ describe('FlowVisualizer (Drawflow)', () => {
const dblClickEvent = new MouseEvent('dblclick', { bubbles: true });
node.dispatchEvent(dblClickEvent);

const modal = document.querySelector('.node-editor-modal');
// Query THIS visualizer's modal. Each FlowVisualizer appends its own
// .node-editor-modal to document.body, so a bare document.querySelector
// can return a different visualizer's modal.
const modal = visualizer.nodeEditorModal;
const modalTitle = modal.querySelector('.node-editor-title');
expect(modal.style.display).toBe('flex');
expect(modalTitle.textContent).toContain('Step 1');
Expand All @@ -268,7 +287,9 @@ describe('FlowVisualizer (Drawflow)', () => {
const node = container.querySelector('.drawflow-node.flow-node[data-step-id="step1"]');
node.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));

const addButton = document.querySelector('.node-editor-add');
// Use THIS visualizer's add button; a bare document.querySelector can
// return the button of another visualizer's modal (see modal note above).
const addButton = visualizer.nodeEditorModal.querySelector('.node-editor-add');
addButton.click();

expect(mockCallbacks.onRequestAddStepAfter).toHaveBeenCalledWith(
Expand Down
17 changes: 17 additions & 0 deletions __tests__/transformOps.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,21 @@ describe('transformOps', () => {
expect(context.decoded.payload.exp).toBe(123);
expect(context.decoded.header.alg).toBe('none');
});

test('unknown transform op is skipped with a warning (not downgraded to base64_decode)', async () => {
const context = {};
const ops = [
// "SGVsbG8" base64url-decodes to "Hello"; the old bug set decoded = "Hello".
{ op: 'totally_unknown_future_op', set: 'decoded', args: ['SGVsbG8'], options: {} },
{ op: 'base64_encode', set: 'enc', args: ['hi'], options: { base64: 'url' } },
];
const output = await executeTransformOps(ops, context, { evaluatePath: simpleEvaluatePath });
expect(context.decoded).toBeUndefined(); // unknown op skipped, NOT base64-decoded
expect(context.enc).toBeDefined(); // subsequent known op still ran
expect(output.updatedVars).toEqual(['enc']);
expect(output.warnings).toHaveLength(1);
expect(output.warnings[0].op).toBe('totally_unknown_future_op');
expect(output.warnings[0].set).toBe('decoded');
expect(output.warnings[0].status).toBe('skipped');
});
});
4 changes: 4 additions & 0 deletions assets/vendor/drawflow/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "commonjs",
"//": "Vendored UMD build of Drawflow. The app's root package.json is \"type\": \"module\", which would make Node treat this .js as ESM and break the Jest setup's require() of it. This marker keeps only this vendored dir CommonJS so it stays require-able in Node/Jest. The browser loads drawflow.min.js via a plain <script> tag, so this has no effect at runtime."
}
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- **CI:** added `schemas/**` to `paths-ignore` so schema-doc changes don't trigger a rebuild.
- **Fix (schema drift):** `schemas/flow-v1.schema.json` declared the internal model names `thenSteps`/`elseSteps`/`loopSteps`; corrected to the real wire keys `then`/`else`/`steps` (verified against sample flows and `flowCore.js:111-116`; the CLI reads only these).
- **Research / proposal:** produced the multi-agent councilled [FlowMap & UX evolution proposal](docs/flowmap-evolution.md) and documented the **cross-app FlowMap contract** (three apps share `.flow.json`: FlowRunner UI, flowrunner-cli, ShowRunner portal) in `architecture.md`/`gotchas.md`/`masterplan.md` + the schema `$comment`. **Direction:** sequence over big-bang; graceful-degradation (unknown step/op ⇒ skip-with-warning, never crash) first; adopt React Flow for the node view only behind packaged-build evidence; `schemaVersion` + subflows gated on *deployed* CLI support.
- **Fix (graceful degradation — JS engine, parity with the CLI):** the runner no longer **crashes** on an unknown step type (`flowRunner.js` threw → halted the whole run) and `transformOps.js` no longer **silently downgrades** an unknown transform op to `base64_decode` (the exact bug just fixed in flowrunner-cli). Both now **skip-with-a-machine-readable-warning** and continue: an unknown step type yields a `status:'skipped', unsupported:true` result + a user-visible warning; an unknown transform op is recorded in `executeTransformOps`' `warnings[]` (`TRANSFORM_OP_UNSUPPORTED` marker) and surfaced by the transform step. `normalizeTransformOp` is intentionally left tolerant because the *editor* (`flowStepComponents.js`) calls it — the editor's own silent rewrite of unknown ops on open is a separate follow-up. This is schema-evolution P1 from the proposal.
- **Test harness fix:** `npm test` was fully broken — `__tests__/setup.js` `require()`s the vendored UMD `drawflow.min.js`, which Node treats as ESM under the app's root `"type": "module"`, so **every** suite failed to load (CI never caught it — `build.yml` runs only `npm run dist`). Added `assets/vendor/drawflow/package.json` (`"type": "commonjs"`) scoping just that vendored dir to CJS (browser `<script>` load unaffected). This unblocked the suite (**122 passing**) and revealed **4 pre-existing failures in `flowVisualizer.test.js`** (Drawflow drag/double-click/add-step under jsdom) — pre-dates this change, tracked as a follow-up.

---

Expand Down
20 changes: 19 additions & 1 deletion flowRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,19 @@ export class FlowRunner {
result = await this._executeTransformStep(processedStep, stepContext);
break;
default:
throw new Error(`Unknown step type: ${processedStep.type}`);
// Graceful degradation: an unknown/newer step type is SKIPPED with a
// machine-readable warning instead of throwing (which would halt the
// whole run). status 'skipped' is not an error, so the flow continues.
result = {
status: 'skipped',
output: `Skipped: unsupported step type "${processedStep.type}".`,
error: null,
extractionFailures: [],
unsupported: true,
};
logger.warn(`[FlowRunner] STEP_TYPE_UNSUPPORTED id=${step.id} type=${JSON.stringify(processedStep.type)} - skipped, not executed.`);
this.onMessage(`Skipped step "${step.name || step.id}": unsupported step type "${processedStep.type}".`, 'warning');
break;
}

// Never overwrite the freshly-created context of a loop level when
Expand Down Expand Up @@ -520,6 +532,12 @@ export class FlowRunner {
try {
const ops = Array.isArray(step.ops) ? step.ops : [];
const output = await executeTransformOps(ops, context, { evaluatePath: this.evaluatePathFn });
if (Array.isArray(output.warnings) && output.warnings.length > 0) {
for (const warning of output.warnings) {
logger.warn(`[FlowRunner] Transform step "${step.name}" (ID: ${step.id}): ${warning.message}`);
this.onMessage(`Transform step "${step.name || step.id}": ${warning.message}`, 'warning');
}
}
return { status: 'success', output: output, error: null };
} catch (error) {
return { status: 'error', output: null, error: error.message || 'Transform step failed' };
Expand Down
6 changes: 6 additions & 0 deletions gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ The `.flow.json` format is shared by **three independently-versioned apps**: Flo
- The `release` job is gated `if: github.event_name == 'push' && ref is main/master`, so **only a real push/merge to main publishes** — PR and manual runs never touch the live release (verified: dispatch run → `release` skipped). Build a branch without publishing: `gh workflow run "FlowRunner Build" --ref <branch>`, then `gh run download <run-id>`.
- Trap: `workflow_dispatch`/`pull_request` only work once the workflow file defining them is on the **default branch** — you can't dispatch a trigger that exists only on a feature branch.

### 2c. `npm test` was broken: vendored UMD `require()` under `"type": "module"`
- **Symptom:** every Jest suite fails to load with `Must use import to load ES Module: .../drawflow.min.js` (0 tests run). Went unnoticed because **CI never runs `npm test`** (`build.yml` runs only `npm run dist`).
- **Cause:** `__tests__/setup.js` `require()`s the vendored **UMD** `assets/vendor/drawflow/drawflow.min.js`; the root `package.json` `"type": "module"` makes Node treat that `.js` as ESM, so `require()` throws.
- **Fix:** `assets/vendor/drawflow/package.json` = `{"type": "commonjs"}` scopes only that vendored dir to CJS (the browser loads Drawflow via a `<script>` tag, so runtime is unaffected; the file isn't imported by the app).
- **Note:** fixing this unmasked **4 pre-existing failures in `flowVisualizer.test.js`** (Drawflow drag/double-click/add-step under jsdom). They pre-date the fix — treat as a known follow-up, not a regression.

## Execution engine

### 3. `substituteVariablesInStep` depends on being called as a method
Expand Down
17 changes: 17 additions & 0 deletions transformOps.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ export async function executeTransformOps(ops, context, options = {}) {
const output = { updatedVars: [], warnings: [] };
const list = Array.isArray(ops) ? ops : [];
for (let i = 0; i < list.length; i++) {
// Graceful degradation: an unknown/newer transform op is SKIPPED with a
// machine-readable warning rather than silently downgraded to base64_decode
// (which would run the wrong operation) or thrown (which would fail the step).
const rawOp = list[i] && typeof list[i] === 'object' ? list[i] : {};
if (!isTransformOpName(rawOp.op)) {
const setHint = typeof rawOp.set === 'string' ? rawOp.set : null;
output.warnings.push({
type: 'unsupported_transform_op',
op: rawOp.op ?? null,
set: setHint,
index: i,
status: 'skipped',
message: `Unsupported transform op "${rawOp.op}" at position ${i + 1}; skipped (${setHint ? `output variable "${setHint}" left unset` : 'no output variable set'}).`,
});
console.warn(`[transformOps] TRANSFORM_OP_UNSUPPORTED op=${JSON.stringify(rawOp.op)} set=${JSON.stringify(setHint)} index=${i} - skipped, not executed (refusing to silently substitute base64_decode).`);
continue;
}
const normalized = normalizeTransformOp(list[i]);
if (!normalized.set || typeof normalized.set !== 'string') {
throw new Error(`Transform op ${i + 1} is missing a valid output variable.`);
Expand Down
Loading