import '../../../dist/zn.min.js'; import {expect, fixture, html} from '@open-wc/testing'; import {type FlowNodeType, type FlowState, NEW_OUTPUT_PORT, NODE_HEIGHT, NODE_WIDTH} from './flow.types'; import type ZnFlowBuilder from './flow-builder.component'; const TRIGGER: FlowNodeType = {type: 'webhook', label: 'Webhook', group: 'trigger', category: 'Contacts'}; const ACTION: FlowNodeType = {type: 'email', label: 'Send Email', group: 'action'}; describe('', () => { it('should render a component', async () => { const el = await fixture(html` `); expect(el).to.exist; expect(el.shadowRoot?.querySelector('zn-flow-canvas')).to.exist; }); it('should list registered node types for the active tab', async () => { const el = await fixture(html` `); el.registerNodeTypes([TRIGGER, ACTION]); await el.updateComplete; // Each group renders its own zn-tabs panel; the default (Triggers) panel is first. const triggerPanel = el.shadowRoot?.querySelectorAll('.steps-panel')[0]; const items = triggerPanel?.querySelectorAll('.step'); expect(items?.length).to.equal(1); // only the trigger shows on the default Triggers tab expect(items?.[0].textContent).to.contain('Webhook'); }); it('should round-trip state through getState/setState, keeping positions', async () => { const el = await fixture(html` `); const state: FlowState = { nodes: [{id: 'n1', type: 'webhook', x: 10, y: 20, data: {}}], connections: [], notes: [{id: 'note1', x: 300, y: 40, width: 220, height: 120, text: 'hi'}], }; el.setState(state); await el.updateComplete; // Save (value) then load (value =) — positional data survives the trip. const json = el.value; el.setState({nodes: [], connections: [], notes: []}); el.value = json; const loaded = el.getState(); expect(loaded.nodes[0]).to.deep.include({id: 'n1', x: 10, y: 20}); expect(loaded.notes[0]).to.deep.include({x: 300, y: 40, width: 220, height: 120}); }); it('should serialize to the full state via toJSON, ready for a POST body', async () => { const el = await fixture(html` `); el.setState({ nodes: [{id: 'n1', type: 'webhook', x: 60, y: 80, data: {}}], connections: [], notes: [], }); const body = JSON.parse(JSON.stringify(el)) as FlowState; expect(body.nodes[0]).to.deep.include({id: 'n1', x: 60, y: 80}); expect(JSON.stringify(el)).to.equal(el.value); }); it('should parse the value attribute as JSON', async () => { const el = await fixture(html` `); el.value = JSON.stringify({nodes: [{id: 'a', type: 'x', x: 0, y: 0, data: {}}]}); await el.updateComplete; expect(el.getState().nodes).to.have.length(1); }); it('should treat undo as a no-op with empty history', async () => { const el = await fixture(html` `); expect(() => el.undo()).to.not.throw(); expect(el.getState().nodes).to.have.length(0); }); it('should tuck the side panels away via the edge chevrons', async () => { const el = await fixture(html` `); const builder = el.shadowRoot!.querySelector('.builder')!; (el.shadowRoot!.querySelector('.panel-toggle--left') as HTMLButtonElement).click(); (el.shadowRoot!.querySelector('.panel-toggle--right') as HTMLButtonElement).click(); await el.updateComplete; expect(builder.classList.contains('builder--steps-collapsed')).to.equal(true); expect(builder.classList.contains('builder--side-collapsed')).to.equal(true); // Selecting a node needs the inspector — the right panel comes back. el.setState({nodes: [{id: 'n1', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []}); el.dispatchEvent(new CustomEvent('flow-node-select', {detail: {nodeId: 'n1'}})); await el.updateComplete; expect(builder.classList.contains('builder--side-collapsed')).to.equal(false); expect(builder.classList.contains('builder--steps-collapsed')).to.equal(true); }); describe('auto-save', () => { const KEY = 'zn-flow-builder:as-test'; const STATE: FlowState = { nodes: [{id: 'n1', type: 'webhook', x: 40, y: 80, data: {}}], connections: [], notes: [], }; const tick = (ms: number) => new Promise(r => setTimeout(r, ms)); afterEach(() => localStorage.removeItem(KEY)); it('should not auto-save without the attribute', async () => { const el = await fixture(html` `); el.setState(STATE); expect(el.autoSave).to.equal(null); await tick(150); expect(localStorage.getItem(KEY)).to.equal(null); }); it('should default a bare attribute to 5 minutes', async () => { const el = await fixture(html` `); expect(el.autoSave).to.equal(5); }); it('should save the state (with positions) on the interval', async () => { const el = await fixture(html` `); el.setState(STATE); await tick(150); const saved = JSON.parse(localStorage.getItem(KEY)!) as { savedAt: number; state: FlowState }; expect(saved.savedAt).to.be.a('number'); expect(saved.state.nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80}); }); it('should show the status pill: a saved flash, then time since last save', async function (this: Mocha.Context) { this.timeout(6000); // outlasts the 2.5s "Auto-saved" flash const el = await fixture(html` `); expect(el.shadowRoot?.querySelector('.save-status')).to.equal(null); el.setState(STATE); await tick(150); await el.updateComplete; const status = el.shadowRoot?.querySelector('.save-status'); expect(status?.textContent).to.contain('Auto-saved'); expect(status?.classList.contains('save-status--saved')).to.equal(true); // Slow the schedule right down; once the flash runs out, the pill // reports how long since the last save. el.autoSave = 60; await tick(2700); await el.updateComplete; expect(el.shadowRoot?.querySelector('.save-status')?.textContent).to.contain('Last saved'); }); it('should offer to restore a differing auto-save on load', async () => { localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE})); const el = await fixture(html` `); // Loading a different flow — the draft is offered. el.setState({nodes: [{id: 'other', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []}); await el.updateComplete; const banner = el.shadowRoot?.querySelector('.restore-banner'); expect(banner?.textContent).to.contain('differs from this flow'); (banner?.querySelector('.restore-banner__restore') as HTMLButtonElement).click(); await el.updateComplete; expect(el.getState().nodes[0].id).to.equal('n1'); expect(el.shadowRoot?.querySelector('.restore-banner')).to.equal(null); // Loading the same flow as the draft — nothing to offer. el.setState(JSON.parse(JSON.stringify(STATE)) as FlowState); await el.updateComplete; expect(el.shadowRoot?.querySelector('.restore-banner')).to.equal(null); }); it('should keep the offered draft intact across ticks, so Restore still applies it', async () => { localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE})); const el = await fixture(html` `); el.setState({nodes: [{id: 'other', type: 'webhook', x: 0, y: 0, data: {}}], connections: [], notes: []}); await el.updateComplete; expect(el.shadowRoot?.querySelector('.restore-banner')).to.not.equal(null); // Several auto-save ticks pass while the prompt is open — the draft // must not be overwritten by the loaded flow. await tick(250); const saved = JSON.parse(localStorage.getItem(KEY)!) as { state: FlowState }; expect(saved.state.nodes[0].id).to.equal('n1'); (el.shadowRoot?.querySelector('.restore-banner__restore') as HTMLButtonElement).click(); await el.updateComplete; expect(el.getState().nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80}); }); it('should restore a fresh auto-save and purge an expired one', async () => { const el = await fixture(html` `); localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now(), state: STATE})); expect(el.restoreAutoSave()).to.equal(true); expect(el.getState().nodes[0]).to.deep.include({id: 'n1', x: 40, y: 80}); // Past the 1-day TTL: not restored, and cleaned out of storage. localStorage.setItem(KEY, JSON.stringify({savedAt: Date.now() - 25 * 60 * 60 * 1000, state: STATE})); expect(el.restoreAutoSave()).to.equal(false); expect(localStorage.getItem(KEY)).to.equal(null); }); }); describe('branch filters declared in markup', () => { const SPLIT_STATE: FlowState = { nodes: [{id: 'n1', type: 'split', x: 0, y: 0, data: {}}], connections: [], notes: [], }; async function makeBuilder(): Promise { const el = await fixture(html` at least at most day(s) month(s) Price `); el.setState(SPLIT_STATE); await el.updateComplete; // Open the TRUE branch's editor, as clicking its pill would. el.dispatchEvent(new CustomEvent('flow-branch-pick', {detail: {nodeId: 'n1', port: 'true'}})); await el.updateComplete; return el; } it('should feed nested declarations to the branch conditions editor', async () => { const el = await makeBuilder(); const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions'); expect(editor).to.exist; expect(editor?.filters).to.have.length(2); expect(editor?.filters[0]).to.deep.include({id: 'engagement', label: 'Email engagement'}); expect(editor?.filters[0].fields[0].operators).to.deep.equal([{value: 'at least'}, {value: 'at most'}]); expect(editor?.filters[0].fields[0].units).to.deep.equal( [{value: 'days', label: 'day(s)'}, {value: 'months', label: 'month(s)'}]); expect(editor?.filters[0].fields[0].value).to.equal(2); expect(editor?.filters[1].fields[0].options).to.deep.equal([{value: 'price', label: 'Price'}]); }); it('should persist saved conditions on the output port', async () => { const el = await makeBuilder(); const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions'); const conditions = [[{filter: 'engagement', values: {count: {operator: 'at least', value: 3}}}]]; editor?.dispatchEvent(new CustomEvent('flow-conditions-save', { bubbles: true, composed: true, detail: {conditions}, })); await el.updateComplete; const port = el.getState().nodes[0].outputs?.find(p => p.id === 'true'); expect(port?.data?.conditions).to.deep.equal(conditions); // Saving closes the branch editor. expect(el.shadowRoot?.querySelector('zn-flow-branch-conditions')).to.not.exist; }); it('should parse the branch-filters JSON attribute', async () => { const el = await fixture(html` `); el.setState(SPLIT_STATE); await el.updateComplete; el.dispatchEvent(new CustomEvent('flow-branch-pick', {detail: {nodeId: 'n1', port: 'true'}})); await el.updateComplete; const editor = el.shadowRoot?.querySelector('zn-flow-branch-conditions'); expect(editor?.filters).to.deep.equal([ {id: 'plan', label: 'Plan', fields: [{id: 'name', options: [{value: 'Basic'}, {value: 'Pro'}]}]}, ]); }); }); describe('fixed outputs', () => { const FIXED: FlowNodeType = { type: 'step', label: 'Step', group: 'action', fixedOutputs: true, outputs: [ {id: 'success', label: 'Success'}, {id: 'failure', label: 'Failure'}, {id: 'skip', label: 'Skip'}, ], }; const wiredState: FlowState = { nodes: [ {id: 'n1', type: 'step', x: 0, y: 0, data: {}}, {id: 'n2', type: 'step', x: 0, y: 220, data: {}}, ], connections: [ {id: 'c1', source: {node: 'n1', port: 'success'}, target: {node: 'n2', port: 'in'}}, {id: 'c2', source: {node: 'n1', port: 'failure'}, target: {node: 'n2', port: 'in'}}, {id: 'c3', source: {node: 'n1', port: 'skip'}, target: {node: 'n2', port: 'in'}}, ], notes: [], }; const makeFixed = async (state: FlowState) => { const el = await fixture(html` `); el.registerNodeTypes([FIXED]); el.setState(state); await el.updateComplete; return el; }; it('should refuse to delete one of a fixed type\'s branches', async () => { const el = await makeFixed(wiredState); el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}})); await el.updateComplete; const node = el.getState().nodes[0]; expect(node.outputs ?? FIXED.outputs).to.have.length(3); expect(el.getState().connections).to.have.length(3); }); it('should not offer a delete on a fixed type\'s branch pills', async () => { const el = await makeFixed(wiredState); const canvas = el.shadowRoot?.querySelector('zn-flow-canvas'); expect(canvas?.shadowRoot?.querySelector('.branch-pill')).to.exist; expect(canvas?.shadowRoot?.querySelector('.branch-pill-delete')).to.not.exist; }); it('should not grow a fixed type an extra branch when every one is wired', async () => { const el = await makeFixed(wiredState); el.dispatchEvent(new CustomEvent('flow-link-assign', { detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'}, })); await el.updateComplete; const node = el.getState().nodes[0]; expect(node.outputs ?? FIXED.outputs).to.have.length(3); expect(el.getState().connections).to.have.length(3); }); it('should still let an ordinary type grow a branch', async () => { const el = await fixture(html` `); el.registerNodeTypes([{...FIXED, fixedOutputs: false}]); el.setState(wiredState); await el.updateComplete; el.dispatchEvent(new CustomEvent('flow-link-assign', { detail: {nodeId: 'n1', port: NEW_OUTPUT_PORT, targetId: 'n2'}, })); await el.updateComplete; expect(el.getState().nodes[0].outputs).to.have.length(4); }); it('should read fixed-outputs off a declared step', async () => { const el = await fixture(html` `); el.setState({ nodes: [{id: 'n1', type: 'step', x: 0, y: 0, data: {}}], connections: [], notes: [], }); await el.updateComplete; el.dispatchEvent(new CustomEvent('flow-branch-delete', {detail: {nodeId: 'n1', port: 'failure'}})); await el.updateComplete; expect(el.getState().nodes[0].outputs).to.be.undefined; // untouched: still the type's two }); }); describe('wire routing', () => { const TRIPLE: FlowNodeType = { type: 'step', label: 'Step', group: 'action', fixedOutputs: true, outputs: [ {id: 'success', label: 'Success'}, {id: 'failure', label: 'Failure'}, {id: 'skip', label: 'Skip'}, ], }; // A's failure skips the middle row entirely and lands on C, while A's other // branches and B's success feed the rows in between — the arrangement that // drew A's failure wire straight down through B, so it read as though the // branch ended at B. const SKIP_A_ROW: FlowState = { nodes: [ {id: 'a', type: 'step', x: 200, y: 700, data: {}}, {id: 'b', type: 'step', x: 200, y: 920, data: {}}, {id: 'c', type: 'step', x: 200, y: 1140, data: {}}, ], connections: [ {id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}}, {id: 'c2', source: {node: 'a', port: 'skip'}, target: {node: 'b', port: 'in'}}, {id: 'c3', source: {node: 'a', port: 'failure'}, target: {node: 'c', port: 'in'}}, {id: 'c4', source: {node: 'b', port: 'success'}, target: {node: 'c', port: 'in'}}, ], notes: [], }; const points = (d: string) => { const numbers = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? []; const out: { x: number; y: number }[] = []; for (let i = 0; i + 1 < numbers.length; i += 2) out.push({x: numbers[i], y: numbers[i + 1]}); return out; }; const wirePaths = async (state: FlowState) => { const el = await fixture(html` `); el.registerNodeTypes([TRIPLE]); el.setState(state); await el.updateComplete; const canvas = el.shadowRoot?.querySelector('zn-flow-canvas'); await canvas?.updateComplete; return Array.from(canvas?.shadowRoot?.querySelectorAll('path.wire--link') ?? []) .map(p => points(p.getAttribute('d') ?? '')) .filter(pts => pts.length > 1); }; // Segments are orthogonal, so a crossing is a plain rect overlap. The card's // own edges are fair game: wires legitimately end on the top one. const crossesCard = (pts: { x: number; y: number }[], card: { x: number; y: number }) => { const E = 1; for (let i = 0; i < pts.length - 1; i++) { const minX = Math.min(pts[i].x, pts[i + 1].x); const maxX = Math.max(pts[i].x, pts[i + 1].x); const minY = Math.min(pts[i].y, pts[i + 1].y); const maxY = Math.max(pts[i].y, pts[i + 1].y); if (minX < card.x + NODE_WIDTH - E && maxX > card.x + E && minY < card.y + NODE_HEIGHT - E && maxY > card.y + E) { return true; } } return false; }; it('should route a row-skipping wire around the card it flies over', async () => { const paths = await wirePaths(SKIP_A_ROW); const middle = {x: 200, y: 920}; expect(paths.length).to.be.greaterThan(0); paths.forEach(pts => { expect(crossesCard(pts, middle), `wire ${JSON.stringify(pts)} crosses the middle card`) .to.equal(false); }); }); it('should take the row-skipping wire out to the side, not through the column', async () => { const paths = await wirePaths(SKIP_A_ROW); // A's failure pill sits on A's bus, so its wire is the one leaving highest. const skipping = paths.reduce((lowestY, pts) => (pts[0].y < lowestY[0].y ? pts : lowestY)); expect(skipping[skipping.length - 1].y).to.equal(1140); // ends on C's input expect(skipping.length).to.be.greaterThan(2); // not the straight drop // It leaves the corridor the cards occupy (200..440) on its way past. const outside = skipping.some(p => p.x > 440 || p.x < 200); expect(outside, `expected a detour outside the column, got ${JSON.stringify(skipping)}`).to.equal(true); }); it('should still draw a straight wire when the corridor is clear', async () => { const paths = await wirePaths({ nodes: [ {id: 'a', type: 'step', x: 200, y: 700, data: {}}, {id: 'b', type: 'step', x: 200, y: 920, data: {}}, ], connections: [{id: 'c1', source: {node: 'a', port: 'success'}, target: {node: 'b', port: 'in'}}], notes: [], }); expect(paths).to.have.length(1); expect(paths[0]).to.have.length(2); expect(paths[0][0].x).to.equal(paths[0][1].x); }); }); });