Files
markdown-converter/tests/flowchart-controller.test.js
T
amitwh 1a357bbd6a feat(flowchart): keyboard shortcuts overlay (C19)
After 17 commits of feature work the standalone window had a lot of
hidden keyboard shortcuts (Cmd+Z, Cmd+C/V/D, Delete, Alt+drag, etc.)
that no user could discover. This adds:

  - 'Shortcuts (?)' toolbar button next to Export Visio.
  - '?' key (or Shift+/) toggles the overlay; Esc closes it.
  - A read-only modal that lists every canvas-relevant shortcut in
    a 2-column table (Action / Shortcut) with <kbd> tags for the
    key combos, styled to look like physical keys.
  - Click on the dimmed overlay backdrop also closes it.
  - Reuses the existing .fc-modal fade + scale-in animation.

The overlay sits inside #fc-left so it overlays the canvas, not the
side panel. Hidden by default via the HTML hidden attribute.

Implementation: btnHelp / shortcutsModal / shortcutsOverlay /
shortcutsClose are added to the bundle's els bag. A small
showShortcutsOverlay / hideShortcutsOverlay pair toggles their
.hidden. The keydown handler gets two new branches at the top:

  - Esc: close the overlay if open.
  - ? (computed from ev.key === '?' or 'Shift+/', no modifier
    keys held): toggle the overlay.

Both branches return early so the existing Cmd+Z / Cmd+C / etc.
handlers below them still work.

Tests: 3 new (1386 total) covering the toolbar toggle, the
? + Esc keyboard flow, and that every shortcut the user can
trigger is listed in the modal.

Amit Haridas
2026-09-30 22:43:05 +05:30

795 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @jest-environment jsdom
*
* Tests the standalone Flowchart Generator window's renderer controller
* (src/renderer/flowchart-controller.js). Loads the real HTML and the
* pure-module + controller scripts into jsdom, mocks
* window.electronAPI.flowchart, and asserts:
* - The stylesheet links are src/-relative (no `..` escape).
* - bootstrap() resolves getUserDataPath exactly once and reads
* <userData>/flowchart-session.json.
* - "Insert at Cursor" wraps the Mermaid source in a fenced code block.
* - Reset clears nodes/edges and persists the empty graph.
* - The sidebar Flow Chart panel registration is commented out of
* src/renderer.js (regression: ensure the legacy sidebar doesn't
* re-appear).
*/
const fs = require('fs');
const path = require('path');
const HTML_PATH = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
const CONTROLLER_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-controller.js');
const RENDERER_PATH = path.join(__dirname, '..', 'src', 'renderer.js');
async function mount(apiMockOverrides = {}) {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
// Strip <head>/<body> wrappers — jsdom already provides those.
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
const apiMock = {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
insertAtCursor: jest.fn(),
...apiMockOverrides,
};
window.electronAPI = { flowchart: apiMock };
// Load the pure-module UMD scripts so the controller sees them as
// window globals. Each is a self-contained IIFE; running them in the
// current realm is enough — they don't need the DOM. The content of each
// file is hardcoded under src/ (no user input) — same pattern used by
// tests/ascii-controller.test.js.
const scriptPaths = [
'flowchart/flowchart-shapes.js',
'flowchart/flowchart-mermaid.js',
'flowchart/flowchart-viewport.js',
'flowchart/flowchart-store.js',
'flowchart/flowchart-canvas.js',
];
for (const rel of scriptPaths) {
const src = fs.readFileSync(path.join(__dirname, '..', 'src', rel), 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', src)(window, document);
}
// Then run the controller itself. The controller's IIFE auto-calls
// bootstrap() at the end because document.readyState is 'complete' in
// jsdom (not 'loading'). Wait for the bootstrap chain — which awaits
// api.getUserDataPath() and api.readFile() — to settle, so callers see a
// fully-initialized store and don't need to call bootstrap() themselves.
const ctrlSrc = fs.readFileSync(CONTROLLER_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', ctrlSrc)(window, document);
// Flush microtasks until the bootstrap's awaits resolve.
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
return { apiMock };
}
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
delete window.electronAPI;
delete window.FlowchartController;
delete window.FlowchartShapes;
delete window.FlowchartMermaid;
delete window.FlowchartStore;
delete window.FlowchartCanvas;
delete window.FlowchartModals;
});
describe('flowchart-generator.html — stylesheet links', () => {
test('all stylesheet hrefs are src/-relative (no "../" escape)', () => {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const linkRe = /<link[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
const hrefs = [];
let match;
while ((match = linkRe.exec(html)) !== null) hrefs.push(match[1]);
expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) {
expect(href.startsWith('../')).toBe(false);
}
});
test('links fonts.css as the first stylesheet', () => {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
expect(html).toMatch(/<link[^>]*href=["']fonts\.css["']/);
});
});
describe('flowchart-controller: bootstrap', () => {
test('resolves getUserDataPath exactly once on mount', async () => {
const { apiMock } = await mount();
expect(apiMock.getUserDataPath).toHaveBeenCalledTimes(1);
});
test('reads <userData>/flowchart-session.json on mount', async () => {
const { apiMock } = await mount();
expect(apiMock.readFile).toHaveBeenCalledWith('/userdata/flowchart-session.json');
});
test('hydrates the store from a saved session', async () => {
const saved = JSON.stringify({
nodes: [{ id: 'n1', kind: 'process', x: 10, y: 10, label: 'Loaded' }],
edges: [],
});
await mount({ readFile: jest.fn(async () => saved) });
await Promise.resolve();
await Promise.resolve();
const store = window.FlowchartController.store;
expect(store.getGraph().nodes).toHaveLength(1);
expect(store.getGraph().nodes[0].label).toBe('Loaded');
});
});
describe('flowchart-controller: insert at cursor', () => {
test('wraps the Mermaid source in a fenced code block', async () => {
const { apiMock } = await mount();
const store = window.FlowchartController.store;
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Start' });
store.addNode({ kind: 'process', x: 200, y: 0, label: 'End' });
store.connect(store.getGraph().nodes[0].id, store.getGraph().nodes[1].id, 'solid');
document.getElementById('fc-btn-insert').click();
expect(apiMock.insertAtCursor).toHaveBeenCalledTimes(1);
const fenced = apiMock.insertAtCursor.mock.calls[0][0];
expect(fenced.startsWith('```mermaid\n')).toBe(true);
expect(fenced.endsWith('\n```')).toBe(true);
expect(fenced).toContain('flowchart TD');
expect(fenced).toContain('A[Start]');
expect(fenced).toContain('B[End]');
expect(fenced).toContain('A --> B');
});
});
describe('flowchart-controller: reset', () => {
test('clears all nodes and edges after confirm', async () => {
const { apiMock } = await mount();
const store = window.FlowchartController.store;
store.addNode({ kind: 'process', x: 0, y: 0, label: 'One' });
jest.advanceTimersByTime(600);
await Promise.resolve();
expect(apiMock.writeFile).toHaveBeenCalled();
// Mock confirm() to return true and click Reset.
window.confirm = jest.fn(() => true);
document.getElementById('fc-btn-reset').click();
expect(store.getGraph().nodes).toHaveLength(0);
expect(store.getGraph().edges).toHaveLength(0);
// Reset mutation should trigger another persisted write within 500 ms.
apiMock.writeFile.mockClear();
jest.advanceTimersByTime(600);
await Promise.resolve();
expect(apiMock.writeFile).toHaveBeenCalled();
const lastCall = apiMock.writeFile.mock.calls[apiMock.writeFile.mock.calls.length - 1];
expect(lastCall[0]).toBe('/userdata/flowchart-session.json');
expect(JSON.parse(lastCall[1]).nodes).toHaveLength(0);
});
test('does nothing when the user cancels the confirm', async () => {
await mount();
const store = window.FlowchartController.store;
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Keep me' });
window.confirm = jest.fn(() => false);
document.getElementById('fc-btn-reset').click();
expect(store.getGraph().nodes).toHaveLength(1);
expect(store.getGraph().nodes[0].label).toBe('Keep me');
});
});
describe('flowchart-controller: renderer.js sidebar registration is disabled', () => {
test('src/renderer.js no longer registers the flowchart sidebar panel', () => {
const src = fs.readFileSync(RENDERER_PATH, 'utf-8');
// The active code path should NOT call sidebarManager.registerPanel('flowchart', ...).
// Strip /* ... */ block comments (legacy sidebar code is preserved as a
// rollback comment block) AND // line comments so the regex doesn't match
// the comment block describing the legacy registration.
const blockStripped = src.replace(/\/\*[\s\S]*?\*\//g, '');
const stripped = blockStripped
.split('\n')
.filter((line) => !/^\s*\*/.test(line) && !/^\s*\/\//.test(line))
.join('\n');
expect(stripped).not.toMatch(/sidebarManager\.registerPanel\(\s*['"]flowchart['"]/);
});
test('the command-palette entry for "Toggle Sidebar: Flow Chart" is disabled', () => {
const src = fs.readFileSync(RENDERER_PATH, 'utf-8');
// Strip /* ... */ blocks (the legacy registration is preserved inside
// such a block for rollback); only the live, uncommented registration
// would match.
const blockStripped = src.replace(/\/\*[\s\S]*?\*\//g, '');
const liveMatch = blockStripped.match(
/^commandPalette\.register\(\s*['"]Toggle Sidebar: Flow Chart['"]/m
);
expect(liveMatch).toBeNull();
});
});
// v4.9.7 regression — pure modules MUST expose themselves as window globals
// so the standalone Flowchart Generator window can load them via <script>
// tags. In Electron renderer with nodeIntegration:true, `module` is truthy,
// which used to make the UMD wrapper skip its browser-global assignment;
// this guard is now mandatory. We assert the source contains the
// `window.FlowchartXxx = exported;` line so a future refactor can't silently
// drop it again.
describe('flowchart pure modules — UMD browser-global assignment (v4.9.7)', () => {
const cases = [
['flowchart/flowchart-store.js', 'FlowchartStore'],
['flowchart/flowchart-shapes.js', 'FlowchartShapes'],
['flowchart/flowchart-mermaid.js', 'FlowchartMermaid'],
['flowchart/flowchart-canvas.js', 'FlowchartCanvas'],
];
test.each(cases)('%s assigns window.%s = exported', (rel, globalName) => {
const src = fs.readFileSync(path.join(__dirname, '..', 'src', rel), 'utf-8');
expect(src).toMatch(new RegExp(`window\\.${globalName}\\s*=\\s*exported`));
});
});
// v4.9.9 — Electron renderer contexts disable `window.prompt` and
// `window.confirm` (the BrowserWindow returns undefined when called),
// which broke shape change / edge kind / edge label / reset confirm in
// the standalone Flowchart Generator window. The bundle now ships
// `promptInline` and `confirmInline` (custom DOM overlay modals) and
// exposes them as `window.FlowchartModals` for testing. These tests load
// the real bundle into jsdom so we exercise the actual overlay code.
describe('flowchart-bundle: inline modal helpers (v4.9.9)', () => {
const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
async function loadBundle() {
const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
window.electronAPI = {
flowchart: {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
insertAtCursor: jest.fn(),
},
};
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', bundleSrc)(window, document);
// Flush microtasks so the bootstrap chain settles before tests run.
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
}
test('promptInline resolves with the entered value on OK click', async () => {
await loadBundle();
expect(typeof window.FlowchartModals.promptInline).toBe('function');
const promise = window.FlowchartModals.promptInline({
title: 'Edge kind',
message: 'Enter the new edge kind.',
defaultValue: 'solid',
});
const input = document.querySelector('input');
expect(input).not.toBeNull();
input.value = 'dotted';
const okButton = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === 'OK'
);
expect(okButton).toBeDefined();
okButton.click();
await expect(promise).resolves.toBe('dotted');
expect(document.querySelector('input')).toBeNull();
});
test('promptInline resolves null on Cancel click', async () => {
await loadBundle();
const promise = window.FlowchartModals.promptInline({
title: 'Edge label',
message: 'Enter a label.',
defaultValue: '',
});
const cancelButton = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === 'Cancel'
);
expect(cancelButton).toBeDefined();
cancelButton.click();
await expect(promise).resolves.toBeNull();
expect(document.querySelector('input')).toBeNull();
});
test('promptInline resolves null on Escape key', async () => {
await loadBundle();
const promise = window.FlowchartModals.promptInline({
title: 'Edge kind',
defaultValue: 'solid',
});
const input = document.querySelector('input');
const ev = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
input.dispatchEvent(ev);
await expect(promise).resolves.toBeNull();
});
test('confirmInline resolves true on OK click', async () => {
await loadBundle();
const promise = window.FlowchartModals.confirmInline({
title: 'Reset diagram',
message: 'Clear all nodes and edges?',
});
const okButton = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === 'OK'
);
expect(okButton).toBeDefined();
okButton.click();
await expect(promise).resolves.toBe(true);
});
test('confirmInline resolves false on Cancel click', async () => {
await loadBundle();
const promise = window.FlowchartModals.confirmInline({
title: 'Reset diagram',
message: 'Clear all nodes and edges?',
});
const cancelButton = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === 'Cancel'
);
expect(cancelButton).toBeDefined();
cancelButton.click();
await expect(promise).resolves.toBe(false);
});
test('confirmInline with danger flag renders a Delete primary button', async () => {
await loadBundle();
const promise = window.FlowchartModals.confirmInline({
title: 'Reset diagram',
message: 'Clear all nodes and edges?',
danger: true,
});
const buttons = Array.from(document.querySelectorAll('button'));
const primary = buttons.find((b) => b.textContent === 'Delete');
expect(primary).toBeDefined();
expect(primary.style.background).toBe('rgb(207, 34, 46)');
primary.click();
await expect(promise).resolves.toBe(true);
});
});
// v4.11.0 — the standalone Flowchart Generator window's click-on-canvas
// interactions were unreliable in the user's Electron runtime (the
// v4.10.0 floating selection toolbar still depended on SVG click hit-
// testing). Replaced with a button-driven node-list panel (#fc-nodelist)
// below the canvas. Every mutation — add/delete node, change kind, edit
// label, add/delete edge, change edge kind, edit edge label — is wired
// to explicit buttons and form controls. These tests load the real
// bundle into jsdom and exercise the panel via the real DOM, asserting
// the resulting store mutations.
describe('flowchart-bundle: button-driven node-list panel (v4.11.0)', () => {
const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
async function loadBundle() {
const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
window.electronAPI = {
flowchart: {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
insertAtCursor: jest.fn(),
},
};
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', bundleSrc)(window, document);
// Flush microtasks so bootstrap's awaits settle before tests run.
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
return window.FlowchartController.store;
}
test('all 5 Add Node buttons create a node with the matching kind', async () => {
const store = await loadBundle();
const expectedKinds = ['process', 'decision', 'terminator', 'subroutine', 'document'];
for (const kind of expectedKinds) {
const btn = document.querySelector(`.fc-add-row .fc-btn[data-add="${kind}"]`);
expect(btn).not.toBeNull();
btn.click();
}
const nodes = store.getGraph().nodes;
expect(nodes).toHaveLength(5);
for (let i = 0; i < expectedKinds.length; i += 1) {
expect(nodes[i].kind).toBe(expectedKinds[i]);
}
});
test('node list re-renders with one <li> per node, each with kind-select + label-input + delete', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'One' });
store.addNode({ kind: 'decision', x: 100, y: 0, label: 'Two' });
const ul = document.getElementById('fc-nodelist-ul');
const items = ul.querySelectorAll('li');
expect(items).toHaveLength(2);
for (const li of items) {
expect(li.querySelector('.fc-node-id')).not.toBeNull();
expect(li.querySelector('select')).not.toBeNull();
expect(li.querySelector('input')).not.toBeNull();
expect(li.querySelector('button.fc-delete')).not.toBeNull();
}
expect(document.getElementById('fc-node-count').textContent).toBe('2');
});
test('changing the per-node kind <select> updates the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'X' });
const li = document.getElementById('fc-nodelist-ul').querySelector('li');
const sel = li.querySelector('select');
sel.value = 'terminator';
sel.dispatchEvent(new window.Event('change', { bubbles: true }));
expect(store.getGraph().nodes[0].kind).toBe('terminator');
});
test('editing the per-node label <input> updates the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Old' });
const li = document.getElementById('fc-nodelist-ul').querySelector('li');
const input = li.querySelector('input');
input.value = 'New';
input.dispatchEvent(new window.Event('input', { bubbles: true }));
expect(store.getGraph().nodes[0].label).toBe('New');
});
test('clicking the per-node delete × removes the node from the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Bye' });
const li = document.getElementById('fc-nodelist-ul').querySelector('li');
li.querySelector('button.fc-delete').click();
expect(store.getGraph().nodes).toHaveLength(0);
expect(document.getElementById('fc-nodelist-ul').querySelectorAll('li')).toHaveLength(0);
});
test('+ Edge button (with from + to selects) creates an edge in the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'process', x: 200, y: 0, label: 'B' });
// Both selects should have one option per node (re-rendered on subscribe).
const fromSel = document.getElementById('fc-connect-from');
const toSel = document.getElementById('fc-connect-to');
expect(fromSel.querySelectorAll('option')).toHaveLength(2);
expect(toSel.querySelectorAll('option')).toHaveLength(2);
fromSel.value = store.getGraph().nodes[0].id;
toSel.value = store.getGraph().nodes[1].id;
document.getElementById('fc-connect-btn').click();
const edges = store.getGraph().edges;
expect(edges).toHaveLength(1);
expect(edges[0].fromNodeId).toBe(store.getGraph().nodes[0].id);
expect(edges[0].toNodeId).toBe(store.getGraph().nodes[1].id);
expect(edges[0].kind).toBe('solid');
});
test('selecting the same node for from + to is a no-op (no self-loop edge)', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Solo' });
const fromSel = document.getElementById('fc-connect-from');
const toSel = document.getElementById('fc-connect-to');
fromSel.value = store.getGraph().nodes[0].id;
toSel.value = store.getGraph().nodes[0].id;
document.getElementById('fc-connect-btn').click();
expect(store.getGraph().edges).toHaveLength(0);
});
test('edge list shows each edge with kind-select + label-input + delete ×', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'decision', x: 200, y: 0, label: 'B' });
store.connect(store.getGraph().nodes[0].id, store.getGraph().nodes[1].id, 'dotted');
const ul = document.getElementById('fc-edgelist-ul');
const items = ul.querySelectorAll('li');
expect(items).toHaveLength(1);
const li = items[0];
expect(li.querySelector('.fc-node-id')).not.toBeNull();
expect(li.querySelector('select')).not.toBeNull();
expect(li.querySelector('input')).not.toBeNull();
expect(li.querySelector('button.fc-delete')).not.toBeNull();
expect(document.getElementById('fc-edge-count').textContent).toBe('1');
});
test('changing the per-edge kind <select> updates the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'process', x: 200, y: 0, label: 'B' });
store.connect(store.getGraph().nodes[0].id, store.getGraph().nodes[1].id, 'solid');
const li = document.getElementById('fc-edgelist-ul').querySelector('li');
const sel = li.querySelector('select');
sel.value = 'thick';
sel.dispatchEvent(new window.Event('change', { bubbles: true }));
expect(store.getGraph().edges[0].kind).toBe('thick');
});
test('clicking the per-edge delete × removes the edge from the store', async () => {
const store = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'process', x: 200, y: 0, label: 'B' });
store.connect(store.getGraph().nodes[0].id, store.getGraph().nodes[1].id, 'solid');
const li = document.getElementById('fc-edgelist-ul').querySelector('li');
li.querySelector('button.fc-delete').click();
expect(store.getGraph().edges).toHaveLength(0);
});
test('subscribe re-renders the lists on every store mutation', async () => {
const store = await loadBundle();
const nodelistUl = document.getElementById('fc-nodelist-ul');
const edgelistUl = document.getElementById('fc-edgelist-ul');
expect(nodelistUl.querySelectorAll('li')).toHaveLength(0);
expect(edgelistUl.querySelectorAll('li')).toHaveLength(0);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'X' });
store.addNode({ kind: 'decision', x: 200, y: 0, label: 'Y' });
expect(nodelistUl.querySelectorAll('li')).toHaveLength(2);
store.connect(store.getGraph().nodes[0].id, store.getGraph().nodes[1].id, 'solid');
expect(edgelistUl.querySelectorAll('li')).toHaveLength(1);
store.removeNode(store.getGraph().nodes[0].id);
expect(nodelistUl.querySelectorAll('li')).toHaveLength(1);
// Removing a node cascades into removing its edges.
expect(edgelistUl.querySelectorAll('li')).toHaveLength(0);
});
});
// v4.12.0 — per-node fill color picker. The user asked for the ability to
// color individual nodes. The bundle renders a native <input type="color">
// per node row, and `input` events call store.setNodeColor. The default
// (newly-added) value is #ffffff.
describe('flowchart-bundle: per-node color picker (v4.12.0)', () => {
const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
async function loadBundle(apiOverrides = {}) {
const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
const apiMock = {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
insertAtCursor: jest.fn(),
saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/out.mmd' })),
...apiOverrides,
};
window.electronAPI = { flowchart: apiMock };
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', bundleSrc)(window, document);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
return { store: window.FlowchartController.store, apiMock };
}
test('each node row exposes a color <input type="color">', async () => {
const { store } = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const li = document.getElementById('fc-nodelist-ul').querySelector('li');
const colorInput = li.querySelector('input[type="color"]');
expect(colorInput).not.toBeNull();
expect(colorInput.value).toBe('#ffffff');
});
test('changing the color <input> calls store.setNodeColor', async () => {
const { store } = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const li = document.getElementById('fc-nodelist-ul').querySelector('li');
const colorInput = li.querySelector('input[type="color"]');
colorInput.value = '#336699';
colorInput.dispatchEvent(new window.Event('input', { bubbles: true }));
expect(store.getGraph().nodes[0].color).toBe('#336699');
});
test('the canvas SVG <rect> reflects the chosen color after a setNodeColor mutation', async () => {
const { store } = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.setNodeColor(store.getGraph().nodes[0].id, '#abcdef');
await Promise.resolve();
const nodeG = document.querySelector('svg.flowchart-canvas g[data-node-id]');
const rect = nodeG && nodeG.querySelector('rect');
expect(rect).not.toBeNull();
expect(rect.getAttribute('fill')).toBe('#abcdef');
});
});
// v4.12.0 — Save to File. The bundle wires #fc-btn-save to api.saveFile
// with the Mermaid-fenced source and a default filename of 'flowchart.mmd'.
// Cancel / error paths surface in the status text.
describe('flowchart-bundle: Save to File button (v4.12.0)', () => {
const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
async function loadBundle(apiOverrides = {}) {
const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
const apiMock = {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
insertAtCursor: jest.fn(),
saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/out.mmd' })),
...apiOverrides,
};
window.electronAPI = { flowchart: apiMock };
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', bundleSrc)(window, document);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
return { store: window.FlowchartController.store, apiMock };
}
test('Save to File calls api.saveFile with the Mermaid-fenced source', async () => {
const { store, apiMock } = await loadBundle();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'Save' });
const btn = document.getElementById('fc-btn-save');
expect(btn).not.toBeNull();
btn.click();
await Promise.resolve();
await Promise.resolve();
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
const [content, defaultName] = apiMock.saveFile.mock.calls[0];
expect(typeof content).toBe('string');
expect(content.startsWith('```mermaid\n')).toBe(true);
expect(content.endsWith('\n```')).toBe(true);
expect(content).toContain('flowchart TD');
expect(content).toContain('A[Save]');
expect(defaultName).toBe('flowchart.mmd');
});
test('Save to File surfaces "cancel" status when the user dismisses the dialog', async () => {
const { apiMock } = await loadBundle({
saveFile: jest.fn(async () => ({ canceled: true })),
});
document.getElementById('fc-btn-save').click();
await Promise.resolve();
await Promise.resolve();
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
expect(document.getElementById('fc-status').textContent).toBe('Save cancelled');
});
test('Save to File surfaces the error when the IPC handler throws', async () => {
const { apiMock } = await loadBundle({
saveFile: jest.fn(async () => {
throw new Error('disk full');
}),
});
document.getElementById('fc-btn-save').click();
await Promise.resolve();
await Promise.resolve();
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
expect(document.getElementById('fc-status').textContent).toBe('Save failed: disk full');
});
});
// v4.13.0 — Export Image buttons (SVG / PNG / JPG). The bundle wires each
// button to the same export pipeline: SVG is a direct clone-and-serialise
// of the canvas SVG; PNG / JPG rasterise through Image + Canvas. The
// SVG-only path is verified here; raster tests live in the canvas suite.
describe('flowchart-bundle: Export Image buttons (v4.13.0)', () => {
const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js');
const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html');
async function loadBundle(apiOverrides = {}) {
const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8');
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
const apiMock = {
getUserDataPath: jest.fn(async () => '/userdata'),
readFile: jest.fn(async () => null),
writeFile: jest.fn(async () => undefined),
saveFile: jest.fn(async () => ({ canceled: true })),
saveBinary: jest.fn(async () => ({ canceled: true })),
openFile: jest.fn(async () => null),
insertAtCursor: jest.fn(),
...apiOverrides,
};
window.electronAPI = { flowchart: apiMock };
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', bundleSrc)(window, document);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
return { apiMock };
}
test('Export SVG calls api.saveFile with the SVG string and flowchart.svg', async () => {
const { apiMock } = await loadBundle({
saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/flow.svg' })),
});
document.getElementById('fc-btn-export-svg').click();
await Promise.resolve();
await Promise.resolve();
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
const [content, defaultName] = apiMock.saveFile.mock.calls[0];
expect(typeof content).toBe('string');
expect(content.startsWith('<?xml version="1.0"')).toBe(true);
expect(content).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(defaultName).toBe('flowchart.svg');
expect(document.getElementById('fc-status').textContent).toBe('Saved SVG to /tmp/flow.svg');
});
test('Export SVG surfaces "cancel" when the user dismisses the Save dialog', async () => {
const { apiMock } = await loadBundle({
saveFile: jest.fn(async () => ({ canceled: true })),
});
document.getElementById('fc-btn-export-svg').click();
await Promise.resolve();
await Promise.resolve();
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
expect(document.getElementById('fc-status').textContent).toBe('Export cancelled');
});
// v4.13.0 — keyboard shortcuts overlay. Triggered by the toolbar
// button or the ? key, dismissed by the Close button or Esc.
describe('shortcuts overlay (v4.13.0)', () => {
test('toolbar button toggles the overlay open', async () => {
await loadBundle();
const modal = document.getElementById('fc-shortcuts-modal');
expect(modal.hidden).toBe(true);
document.getElementById('fc-btn-help').click();
expect(modal.hidden).toBe(false);
});
test('? key opens the overlay, Esc closes it', async () => {
await loadBundle();
const modal = document.getElementById('fc-shortcuts-modal');
expect(modal.hidden).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }));
expect(modal.hidden).toBe(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(modal.hidden).toBe(true);
});
test('overlay lists the canvas-relevant shortcuts', async () => {
await loadBundle();
document.getElementById('fc-btn-help').click();
const table = document.querySelector('#fc-shortcuts-modal .fc-shortcuts-table');
expect(table).not.toBeNull();
const rows = Array.from(table.querySelectorAll('tbody tr')).map(
(tr) => tr.children[0].textContent
);
expect(rows).toEqual(
expect.arrayContaining([
'Undo',
'Redo',
'Copy selection',
'Paste',
'Duplicate',
'Delete selected',
'Connect from a node',
'Multi-select',
'Show this overlay',
])
);
});
});
});