add folder, file nodes and major usability improvements
This commit is contained in:
117
frontend/tests/defaultWorkflow.test.mjs
Normal file
117
frontend/tests/defaultWorkflow.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { embedWorkflow } from '../src/pngMetadata.js';
|
||||
import { loadDefaultWorkflowAsset } from '../src/defaultWorkflow.js';
|
||||
|
||||
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=';
|
||||
|
||||
function makePngBlob() {
|
||||
return new Blob([Buffer.from(PNG_BASE64, 'base64')], { type: 'image/png' });
|
||||
}
|
||||
|
||||
test('loadDefaultWorkflowAsset prefers checked-in JSON when present', async () => {
|
||||
const workflow = { version: 1, nodes: [{ id: '1' }], edges: [] };
|
||||
const requests = [];
|
||||
const fetchImpl = async (url) => {
|
||||
requests.push(url);
|
||||
if (url === '/default-workflow.json') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return workflow;
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('PNG fallback should not be requested when JSON exists');
|
||||
};
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.deepEqual(loaded, {
|
||||
source: '/default-workflow.json',
|
||||
format: 'json',
|
||||
workflow,
|
||||
});
|
||||
assert.deepEqual(requests, ['/default-workflow.json']);
|
||||
});
|
||||
|
||||
test('loadDefaultWorkflowAsset falls back to PNG workflow metadata when JSON is missing', async () => {
|
||||
const workflow = { version: 1, nodes: [{ id: '2' }], edges: [] };
|
||||
const pngWithWorkflow = await embedWorkflow(makePngBlob(), workflow);
|
||||
const requests = [];
|
||||
const fetchImpl = async (url) => {
|
||||
requests.push(url);
|
||||
if (url === '/default-workflow.json') {
|
||||
return { ok: false, status: 404 };
|
||||
}
|
||||
if (url === '/default-workflow.png') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async blob() {
|
||||
return pngWithWorkflow;
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.deepEqual(loaded, {
|
||||
source: '/default-workflow.png',
|
||||
format: 'png',
|
||||
workflow,
|
||||
});
|
||||
assert.deepEqual(requests, ['/default-workflow.json', '/default-workflow.png']);
|
||||
});
|
||||
|
||||
test('loadDefaultWorkflowAsset returns null when no default workflow asset is present', async () => {
|
||||
const fetchImpl = async () => ({ ok: false, status: 404 });
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.equal(loaded, null);
|
||||
});
|
||||
|
||||
test('loadDefaultWorkflowAsset stays quiet when default assets are simply absent in the host runtime', async () => {
|
||||
const fetchImpl = async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
};
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.equal(loaded, null);
|
||||
});
|
||||
|
||||
test('loadDefaultWorkflowAsset stays quiet when the host serves app HTML for missing default assets', async () => {
|
||||
const fetchImpl = async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
return name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null;
|
||||
},
|
||||
},
|
||||
async json() {
|
||||
throw new SyntaxError(`Unexpected token '<' while parsing ${url}`);
|
||||
},
|
||||
async blob() {
|
||||
return new Blob(['<html></html>'], { type: 'text/html' });
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.equal(loaded, null);
|
||||
});
|
||||
|
||||
test('loadDefaultWorkflowAsset stays quiet when the host reports missing assets with status 0', async () => {
|
||||
const fetchImpl = async () => ({ ok: false, status: 0 });
|
||||
|
||||
const loaded = await loadDefaultWorkflowAsset({ fetchImpl });
|
||||
|
||||
assert.equal(loaded, null);
|
||||
});
|
||||
339
frontend/tests/executionGraph.test.mjs
Normal file
339
frontend/tests/executionGraph.test.mjs
Normal file
@@ -0,0 +1,339 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
serializeExecutionGraph,
|
||||
getAutoRunnableNodes,
|
||||
hasBlockingAutoRunInput,
|
||||
} from '../src/executionGraph.js';
|
||||
|
||||
test('serializeExecutionGraph excludes isolated nodes from the backend prompt', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { filename: 'scan.gwy' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
data: {
|
||||
className: 'PreviewImage',
|
||||
definition: {
|
||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
];
|
||||
|
||||
const prompt = serializeExecutionGraph(nodes, edges);
|
||||
|
||||
assert.deepEqual(prompt, {
|
||||
'1': {
|
||||
class_type: 'LoadFile',
|
||||
inputs: { filename: 'scan.gwy' },
|
||||
},
|
||||
'2': {
|
||||
class_type: 'PreviewImage',
|
||||
inputs: { field: ['1', 0] },
|
||||
},
|
||||
});
|
||||
assert.equal('3' in prompt, false);
|
||||
});
|
||||
|
||||
test('serializeExecutionGraph includes isolated preview-load nodes alongside connected subgraphs', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { filename: 'first.gwy' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
data: {
|
||||
className: 'PreviewImage',
|
||||
definition: {
|
||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
data: {
|
||||
className: 'LoadDemo',
|
||||
definition: {
|
||||
input: { required: { name: [['demo.npy'], {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { name: 'demo.npy' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { filename: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
];
|
||||
|
||||
const prompt = serializeExecutionGraph(nodes, edges);
|
||||
|
||||
assert.deepEqual(prompt, {
|
||||
'1': {
|
||||
class_type: 'LoadFile',
|
||||
inputs: { filename: 'first.gwy' },
|
||||
},
|
||||
'2': {
|
||||
class_type: 'PreviewImage',
|
||||
inputs: { field: ['1', 0] },
|
||||
},
|
||||
'3': {
|
||||
class_type: 'LoadDemo',
|
||||
inputs: { name: 'demo.npy' },
|
||||
},
|
||||
});
|
||||
assert.equal('4' in prompt, false);
|
||||
});
|
||||
|
||||
test('serializeExecutionGraph allows a singleton LoadFile graph so previews can run', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { filename: 'scan.gwy' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const prompt = serializeExecutionGraph(nodes, []);
|
||||
|
||||
assert.deepEqual(prompt, {
|
||||
'1': {
|
||||
class_type: 'LoadFile',
|
||||
inputs: { filename: 'scan.gwy' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeExecutionGraph allows a singleton LoadDemo graph so previews can run', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadDemo',
|
||||
definition: {
|
||||
input: { required: { name: [['demo.npy'], {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { name: 'demo.npy' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const prompt = serializeExecutionGraph(nodes, []);
|
||||
|
||||
assert.deepEqual(prompt, {
|
||||
'1': {
|
||||
class_type: 'LoadDemo',
|
||||
inputs: { name: 'demo.npy' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
|
||||
const nodes = [
|
||||
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
||||
{ id: '2', data: { definition: {}, widgetValues: {} } },
|
||||
{ id: '3', data: { definition: {}, widgetValues: {} } },
|
||||
];
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
];
|
||||
|
||||
const runnable = getAutoRunnableNodes(nodes, edges);
|
||||
|
||||
assert.deepEqual(runnable.map((node) => node.id), ['1', '2']);
|
||||
});
|
||||
|
||||
test('getAutoRunnableNodes includes isolated preview-load nodes with selections', () => {
|
||||
const nodes = [
|
||||
{ id: '1', data: { className: 'LoadFile', definition: {}, widgetValues: { filename: 'first.gwy' } } },
|
||||
{ id: '2', data: { className: 'PreviewImage', definition: {}, widgetValues: {} } },
|
||||
{ id: '3', data: { className: 'LoadDemo', definition: {}, widgetValues: { name: 'demo.npy' } } },
|
||||
{ id: '4', data: { className: 'LoadFile', definition: {}, widgetValues: { filename: '' } } },
|
||||
];
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
];
|
||||
|
||||
const runnable = getAutoRunnableNodes(nodes, edges);
|
||||
|
||||
assert.deepEqual(runnable.map((node) => node.id), ['1', '2', '3']);
|
||||
});
|
||||
|
||||
test('getAutoRunnableNodes allows a singleton LoadFile graph', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadFile',
|
||||
definition: {},
|
||||
widgetValues: { filename: 'scan.gwy' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const runnable = getAutoRunnableNodes(nodes, []);
|
||||
|
||||
assert.deepEqual(runnable.map((node) => node.id), ['1']);
|
||||
});
|
||||
|
||||
test('getAutoRunnableNodes allows a singleton LoadDemo graph', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'LoadDemo',
|
||||
definition: {},
|
||||
widgetValues: { name: 'demo.npy' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const runnable = getAutoRunnableNodes(nodes, []);
|
||||
|
||||
assert.deepEqual(runnable.map((node) => node.id), ['1']);
|
||||
});
|
||||
|
||||
test('hasBlockingAutoRunInput only blocks connected nodes with incomplete required inputs', () => {
|
||||
const node = {
|
||||
id: '2',
|
||||
data: {
|
||||
definition: {
|
||||
manual_trigger: false,
|
||||
input: {
|
||||
required: {
|
||||
field: ['DATA_FIELD', {}],
|
||||
filename: ['FILE_PICKER', {}],
|
||||
},
|
||||
},
|
||||
},
|
||||
widgetValues: { filename: '' },
|
||||
},
|
||||
};
|
||||
const completeEdges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasBlockingAutoRunInput(node, completeEdges), true);
|
||||
assert.equal(
|
||||
hasBlockingAutoRunInput(
|
||||
{
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
widgetValues: { filename: 'scan.gwy' },
|
||||
},
|
||||
},
|
||||
completeEdges,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasBlockingAutoRunInput skips required file widgets when a connected socket overrides them', () => {
|
||||
const node = {
|
||||
id: '2',
|
||||
data: {
|
||||
definition: {
|
||||
manual_trigger: false,
|
||||
input: {
|
||||
required: {
|
||||
filename: ['FILE_PICKER', { hide_when_input_connected: 'path' }],
|
||||
},
|
||||
optional: {
|
||||
path: ['FILE_PATH', {}],
|
||||
},
|
||||
},
|
||||
},
|
||||
widgetValues: { filename: '' },
|
||||
},
|
||||
};
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::FILE_PATH',
|
||||
target: '2',
|
||||
targetHandle: 'input::path::FILE_PATH',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasBlockingAutoRunInput(node, edges), false);
|
||||
});
|
||||
@@ -95,7 +95,7 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
|
||||
assert.equal('selected' in serialized.edges[0], false);
|
||||
});
|
||||
|
||||
test('hydrateWorkflowState restores saved dynamic outputs on top of current node definitions', () => {
|
||||
test('hydrateWorkflowState clears shared path widgets while restoring saved dynamic outputs', () => {
|
||||
const saved = {
|
||||
version: 1,
|
||||
nodes: [
|
||||
@@ -140,12 +140,14 @@ test('hydrateWorkflowState restores saved dynamic outputs on top of current node
|
||||
assert.equal(hydrated.nodes[0].dragHandle, '.drag-handle');
|
||||
assert.equal(hydrated.nodes[0].data.label, 'LoadFile');
|
||||
assert.equal(hydrated.nodes[0].data.previewImage, null);
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.filename, '');
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.colormap, 'viridis');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.LoadFile.input);
|
||||
});
|
||||
|
||||
test('serializeWorkflowState and hydrateWorkflowState preserve reload-critical metadata for dynamic nodes', () => {
|
||||
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets but preserve other metadata', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '7',
|
||||
@@ -185,8 +187,42 @@ test('serializeWorkflowState and hydrateWorkflowState preserve reload-critical m
|
||||
const serialized = serializeWorkflowState(nodes, edges);
|
||||
const hydrated = hydrateWorkflowState(serialized, defs);
|
||||
|
||||
assert.deepEqual(hydrated.nodes[0].data.widgetValues, nodes[0].data.widgetValues);
|
||||
assert.deepEqual(hydrated.nodes[0].data.widgetValues, { filename: '', colormap: 'gray' });
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Topography', 'Error', 'Mask']);
|
||||
assert.deepEqual(hydrated.edges, edges);
|
||||
});
|
||||
|
||||
test('hydrateWorkflowState clears saved folder selections on shared workflows', () => {
|
||||
const saved = {
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: '21',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
className: 'Folder',
|
||||
widgetValues: { folder: '/Users/alice/Desktop/shared-dataset' },
|
||||
output: ['PATH', 'PATH'],
|
||||
output_name: ['scan1.png', 'scan2.png'],
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const defs = {
|
||||
Folder: {
|
||||
category: 'io',
|
||||
input: { required: { folder: ['FOLDER_PICKER', {}] } },
|
||||
output: ['PATH'],
|
||||
output_name: ['path'],
|
||||
},
|
||||
};
|
||||
|
||||
const hydrated = hydrateWorkflowState(saved, defs);
|
||||
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.folder, '');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH', 'PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['scan1.png', 'scan2.png']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user