ultravisor 1.0.19 → 1.0.21
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.
- package/package.json +3 -3
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +1 -1
- package/source/services/Ultravisor-ExecutionEngine.cjs +9 -3
- package/source/services/tasks/data-transform/Ultravisor-TaskConfigs-DataTransform.cjs +72 -0
- package/source/services/tasks/data-transform/definitions/random-number.json +24 -0
- package/source/services/tasks/data-transform/definitions/random-string.json +23 -0
- package/source/services/tasks/extension/Ultravisor-TaskConfigs-Extension.cjs +19 -7
- package/source/services/tasks/user-interaction/Ultravisor-TaskConfigs-UserInteraction.cjs +30 -2
- package/source/services/tasks/user-interaction/Ultravisor-TaskType-ValueInput.cjs +56 -2
- package/source/web_server/Ultravisor-API-Server.cjs +24 -1
- package/test/Ultravisor-Beacon-Reachability_tests.js +520 -0
- package/test/Ultravisor_tests.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultravisor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "Cyclic process execution with ai integration.",
|
|
5
5
|
"main": "source/Ultravisor.cjs",
|
|
6
6
|
"bin": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"orator": "^6.0.4",
|
|
32
32
|
"orator-authentication": "^1.0.0",
|
|
33
33
|
"orator-serviceserver-restify": "^2.0.10",
|
|
34
|
-
"pict": "^1.0.
|
|
34
|
+
"pict": "^1.0.362",
|
|
35
35
|
"pict-service-commandlineutility": "^1.0.19",
|
|
36
36
|
"pict-serviceproviderbase": "^1.0.4",
|
|
37
|
-
"ultravisor-beacon": "^0.0.
|
|
37
|
+
"ultravisor-beacon": "^0.0.11",
|
|
38
38
|
"ws": "^8.20.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
@@ -1521,7 +1521,7 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1521
1521
|
let tmpContext = tmpManifest.getRun(tmpWorkItem.RunHash);
|
|
1522
1522
|
if (tmpContext && tmpContext.WaitingTasks[tmpWorkItem.NodeHash])
|
|
1523
1523
|
{
|
|
1524
|
-
tmpContext.WaitingTasks[tmpWorkItem.NodeHash].ResumeEventName = '
|
|
1524
|
+
tmpContext.WaitingTasks[tmpWorkItem.NodeHash].ResumeEventName = 'error';
|
|
1525
1525
|
}
|
|
1526
1526
|
}
|
|
1527
1527
|
|
|
@@ -955,11 +955,16 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
955
955
|
_resolveStateConnections(pNodeHash, pNode, pContext)
|
|
956
956
|
{
|
|
957
957
|
// Start with a copy of the node's static settings
|
|
958
|
+
// Nodes may store config in Settings or Data (flow editor uses Data)
|
|
958
959
|
let tmpSettings = {};
|
|
959
960
|
|
|
961
|
+
if (pNode.Data && typeof(pNode.Data) === 'object')
|
|
962
|
+
{
|
|
963
|
+
tmpSettings = JSON.parse(JSON.stringify(pNode.Data));
|
|
964
|
+
}
|
|
960
965
|
if (pNode.Settings && typeof(pNode.Settings) === 'object')
|
|
961
966
|
{
|
|
962
|
-
tmpSettings
|
|
967
|
+
Object.assign(tmpSettings, JSON.parse(JSON.stringify(pNode.Settings)));
|
|
963
968
|
}
|
|
964
969
|
|
|
965
970
|
// Find all incoming State connections targeting this node
|
|
@@ -1081,7 +1086,8 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
1081
1086
|
let tmpType = tmpConn.ConnectionType
|
|
1082
1087
|
|| this._inferConnectionType(tmpConn, pNodeMap, pPortLabelMap);
|
|
1083
1088
|
|
|
1084
|
-
|
|
1089
|
+
let tmpTypeLower = (tmpType || '').toLowerCase();
|
|
1090
|
+
if (tmpTypeLower === 'event')
|
|
1085
1091
|
{
|
|
1086
1092
|
if (!tmpMap.eventSources[tmpConn.SourceNodeHash])
|
|
1087
1093
|
{
|
|
@@ -1089,7 +1095,7 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
1089
1095
|
}
|
|
1090
1096
|
tmpMap.eventSources[tmpConn.SourceNodeHash].push(tmpConn);
|
|
1091
1097
|
}
|
|
1092
|
-
else if (
|
|
1098
|
+
else if (tmpTypeLower === 'state')
|
|
1093
1099
|
{
|
|
1094
1100
|
if (!tmpMap.stateTargets[tmpConn.TargetNodeHash])
|
|
1095
1101
|
{
|
|
@@ -745,5 +745,77 @@ module.exports =
|
|
|
745
745
|
Log: [`Histogram: analyzed ${tmpData.length} records, ${Object.keys(tmpFrequency).length} unique values`]
|
|
746
746
|
});
|
|
747
747
|
}
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
// ── random-number ──────────────────────────────────────────────────
|
|
751
|
+
{
|
|
752
|
+
Definition: require('./definitions/random-number.json'),
|
|
753
|
+
Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
|
|
754
|
+
{
|
|
755
|
+
let tmpMin = Number(pResolvedSettings.Min);
|
|
756
|
+
let tmpMax = Number(pResolvedSettings.Max);
|
|
757
|
+
let tmpInteger = pResolvedSettings.Integer !== false && pResolvedSettings.Integer !== 'false';
|
|
758
|
+
|
|
759
|
+
if (isNaN(tmpMin)) tmpMin = 0;
|
|
760
|
+
if (isNaN(tmpMax)) tmpMax = 2147483647;
|
|
761
|
+
if (tmpMin >= tmpMax)
|
|
762
|
+
{
|
|
763
|
+
return fCallback(null, {
|
|
764
|
+
EventToFire: 'Error',
|
|
765
|
+
Outputs: { Value: 0 },
|
|
766
|
+
Log: [`RandomNumber: Min (${tmpMin}) must be less than Max (${tmpMax})`]
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let tmpValue = Math.random() * (tmpMax - tmpMin) + tmpMin;
|
|
771
|
+
if (tmpInteger)
|
|
772
|
+
{
|
|
773
|
+
tmpValue = Math.floor(tmpValue);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return fCallback(null, {
|
|
777
|
+
EventToFire: 'Complete',
|
|
778
|
+
Outputs: { Value: tmpValue },
|
|
779
|
+
Log: [`RandomNumber: generated ${tmpValue} in range [${tmpMin}, ${tmpMax})${tmpInteger ? ' (integer)' : ''}`]
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
// ── random-string ──────────────────────────────────────────────────
|
|
785
|
+
{
|
|
786
|
+
Definition: require('./definitions/random-string.json'),
|
|
787
|
+
Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
|
|
788
|
+
{
|
|
789
|
+
let tmpLength = parseInt(pResolvedSettings.Length, 10) || 16;
|
|
790
|
+
let tmpFormat = (pResolvedSettings.Format || 'hex').toLowerCase();
|
|
791
|
+
let tmpValue = '';
|
|
792
|
+
|
|
793
|
+
if (tmpFormat === 'uuid')
|
|
794
|
+
{
|
|
795
|
+
// RFC 4122 v4 UUID
|
|
796
|
+
tmpValue = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,
|
|
797
|
+
(c) =>
|
|
798
|
+
{
|
|
799
|
+
let r = Math.random() * 16 | 0;
|
|
800
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
else
|
|
804
|
+
{
|
|
805
|
+
let tmpChars = tmpFormat === 'alphanumeric'
|
|
806
|
+
? 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
807
|
+
: '0123456789abcdef';
|
|
808
|
+
for (let i = 0; i < tmpLength; i++)
|
|
809
|
+
{
|
|
810
|
+
tmpValue += tmpChars.charAt(Math.floor(Math.random() * tmpChars.length));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return fCallback(null, {
|
|
815
|
+
EventToFire: 'Complete',
|
|
816
|
+
Outputs: { Value: tmpValue },
|
|
817
|
+
Log: [`RandomString: generated ${tmpFormat} string (${tmpValue.length} chars)`]
|
|
818
|
+
});
|
|
819
|
+
}
|
|
748
820
|
}
|
|
749
821
|
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Hash": "random-number",
|
|
3
|
+
"Type": "random-number",
|
|
4
|
+
"Name": "Random Number",
|
|
5
|
+
"Description": "Generates a random number within a specified range. Use Integer=true for whole numbers (e.g., seeds), false for decimals.",
|
|
6
|
+
"Category": "data",
|
|
7
|
+
"Capability": "Data Transform",
|
|
8
|
+
"Action": "GenerateRandomNumber",
|
|
9
|
+
"Tier": "Engine",
|
|
10
|
+
"EventInputs": [{ "Name": "Generate" }],
|
|
11
|
+
"EventOutputs": [
|
|
12
|
+
{ "Name": "Complete" },
|
|
13
|
+
{ "Name": "Error", "IsError": true }
|
|
14
|
+
],
|
|
15
|
+
"SettingsInputs": [
|
|
16
|
+
{ "Name": "Min", "DataType": "Number", "Required": false, "Description": "Minimum value (inclusive). Default: 0" },
|
|
17
|
+
{ "Name": "Max", "DataType": "Number", "Required": false, "Description": "Maximum value (exclusive for float, inclusive for integer). Default: 2147483647" },
|
|
18
|
+
{ "Name": "Integer", "DataType": "Boolean", "Required": false, "Description": "If true, generate a whole number. Default: true" }
|
|
19
|
+
],
|
|
20
|
+
"StateOutputs": [
|
|
21
|
+
{ "Name": "Value", "DataType": "Number", "Description": "The generated random number" }
|
|
22
|
+
],
|
|
23
|
+
"DefaultSettings": { "Min": 0, "Max": 2147483647, "Integer": true }
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Hash": "random-string",
|
|
3
|
+
"Type": "random-string",
|
|
4
|
+
"Name": "Random String",
|
|
5
|
+
"Description": "Generates a random string. Useful for unique filenames, run IDs, and identifiers.",
|
|
6
|
+
"Category": "data",
|
|
7
|
+
"Capability": "Data Transform",
|
|
8
|
+
"Action": "GenerateRandomString",
|
|
9
|
+
"Tier": "Engine",
|
|
10
|
+
"EventInputs": [{ "Name": "Generate" }],
|
|
11
|
+
"EventOutputs": [
|
|
12
|
+
{ "Name": "Complete" },
|
|
13
|
+
{ "Name": "Error", "IsError": true }
|
|
14
|
+
],
|
|
15
|
+
"SettingsInputs": [
|
|
16
|
+
{ "Name": "Length", "DataType": "Number", "Required": false, "Description": "Length of the generated string. Default: 16" },
|
|
17
|
+
{ "Name": "Format", "DataType": "String", "Required": false, "Description": "Format: hex, alphanumeric, uuid. Default: hex" }
|
|
18
|
+
],
|
|
19
|
+
"StateOutputs": [
|
|
20
|
+
{ "Name": "Value", "DataType": "String", "Description": "The generated random string" }
|
|
21
|
+
],
|
|
22
|
+
"DefaultSettings": { "Length": 16, "Format": "hex" }
|
|
23
|
+
}
|
|
@@ -46,12 +46,24 @@ module.exports =
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Build work item settings from resolved settings
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
// Build work item settings from ALL resolved settings
|
|
50
|
+
// (includes node Data fields + state connection values)
|
|
51
|
+
let tmpSettings = {};
|
|
52
|
+
let tmpResolvedKeys = Object.keys(pResolvedSettings);
|
|
53
|
+
for (let rk = 0; rk < tmpResolvedKeys.length; rk++)
|
|
54
|
+
{
|
|
55
|
+
let tmpKey = tmpResolvedKeys[rk];
|
|
56
|
+
// Skip internal/meta fields that aren't work item settings
|
|
57
|
+
if (tmpKey === 'RemoteCapability' || tmpKey === 'RemoteAction'
|
|
58
|
+
|| tmpKey === 'AffinityKey' || tmpKey === 'TimeoutMs'
|
|
59
|
+
|| tmpKey === 'PromptMessage' || tmpKey === 'OutputAddress'
|
|
60
|
+
|| tmpKey === 'InputSchema') continue;
|
|
61
|
+
tmpSettings[tmpKey] = pResolvedSettings[tmpKey];
|
|
62
|
+
}
|
|
63
|
+
// Ensure legacy fields exist for backward compat
|
|
64
|
+
if (!tmpSettings.Command) tmpSettings.Command = '';
|
|
65
|
+
if (!tmpSettings.Parameters) tmpSettings.Parameters = '';
|
|
66
|
+
if (!tmpSettings.InputData) tmpSettings.InputData = '';
|
|
55
67
|
|
|
56
68
|
// Resolve universal addresses in InputData (JSON string).
|
|
57
69
|
// Addresses like >retold-remote/File/path become concrete
|
|
@@ -122,7 +134,7 @@ module.exports =
|
|
|
122
134
|
// Pause execution — the BeaconCoordinator will call resumeOperation when the Beacon reports back
|
|
123
135
|
return fCallback(null, {
|
|
124
136
|
WaitingForInput: true,
|
|
125
|
-
ResumeEventName: '
|
|
137
|
+
ResumeEventName: 'complete',
|
|
126
138
|
PromptMessage: `Waiting for Beacon worker (${tmpWorkItemInfo.Capability}/${tmpWorkItemInfo.Action})`,
|
|
127
139
|
OutputAddress: '',
|
|
128
140
|
Outputs: {},
|
|
@@ -106,14 +106,42 @@ module.exports =
|
|
|
106
106
|
if (tmpExistingValue !== undefined && tmpExistingValue !== null && tmpExistingValue !== '')
|
|
107
107
|
{
|
|
108
108
|
return fCallback(null, {
|
|
109
|
-
EventToFire: '
|
|
109
|
+
EventToFire: 'complete',
|
|
110
110
|
Outputs: { InputValue: tmpExistingValue },
|
|
111
111
|
Log: [`Auto-resolved from pre-seeded state: "${tmpOutputAddress}" = "${String(tmpExistingValue).substring(0, 100)}"`]
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
//
|
|
116
|
+
// If the operation was triggered programmatically (has pre-seeded params),
|
|
117
|
+
// use the DefaultValue so the whole chain runs without pausing
|
|
118
|
+
let tmpIsProgrammatic = pExecutionContext.OperationState
|
|
119
|
+
&& Object.keys(pExecutionContext.OperationState).length > 0;
|
|
120
|
+
let tmpDefaultValue = pResolvedSettings.DefaultValue
|
|
121
|
+
|| (pResolvedSettings.InputSchema && pResolvedSettings.InputSchema.Default !== undefined
|
|
122
|
+
? String(pResolvedSettings.InputSchema.Default) : undefined);
|
|
123
|
+
if (tmpIsProgrammatic && tmpDefaultValue !== undefined && tmpDefaultValue !== null && tmpDefaultValue !== '')
|
|
124
|
+
{
|
|
125
|
+
return fCallback(null, {
|
|
126
|
+
EventToFire: 'complete',
|
|
127
|
+
Outputs: { InputValue: tmpDefaultValue },
|
|
128
|
+
Log: [`Auto-resolved from default: "${tmpOutputAddress}" = "${String(tmpDefaultValue).substring(0, 100)}"`]
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// For optional fields with no default in programmatic mode, pass empty string
|
|
133
|
+
let tmpIsOptional = pResolvedSettings.InputSchema
|
|
134
|
+
&& pResolvedSettings.InputSchema.Required === false;
|
|
135
|
+
if (tmpIsProgrammatic && tmpIsOptional)
|
|
136
|
+
{
|
|
137
|
+
return fCallback(null, {
|
|
138
|
+
EventToFire: 'complete',
|
|
139
|
+
Outputs: { InputValue: '' },
|
|
140
|
+
Log: [`Auto-resolved optional field: "${tmpOutputAddress}" = "" (no value provided)`]
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// No pre-seeded value and no default — pause and wait for interactive input
|
|
117
145
|
let tmpOptions = pResolvedSettings.Options || '';
|
|
118
146
|
if (Array.isArray(tmpOptions))
|
|
119
147
|
{
|
|
@@ -29,15 +29,69 @@ class UltravisorTaskTypeValueInput extends libTaskTypeBase
|
|
|
29
29
|
let tmpPromptMessage = pResolvedSettings.PromptMessage || 'Please provide a value:';
|
|
30
30
|
let tmpOutputAddress = pResolvedSettings.OutputAddress || '';
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// Auto-resolve: if the output address already has a value in state
|
|
33
|
+
// (e.g., pre-seeded via /Operation/:Hash/Trigger with Parameters),
|
|
34
|
+
// skip the pause and fire immediately. This lets operations work both
|
|
35
|
+
// interactively (flow editor — pauses for input) and programmatically
|
|
36
|
+
// (API trigger / retold-labs operation runner — runs straight through).
|
|
37
|
+
if (tmpOutputAddress && pExecutionContext.StateManager)
|
|
38
|
+
{
|
|
39
|
+
let tmpExistingValue = pExecutionContext.StateManager.resolveAddress(
|
|
40
|
+
tmpOutputAddress, pExecutionContext, pExecutionContext.NodeHash);
|
|
41
|
+
if (tmpExistingValue !== undefined && tmpExistingValue !== null && tmpExistingValue !== '')
|
|
42
|
+
{
|
|
43
|
+
return fCallback(null, {
|
|
44
|
+
EventToFire: 'complete',
|
|
45
|
+
Outputs: { InputValue: tmpExistingValue },
|
|
46
|
+
Log: [`Auto-resolved from pre-seeded state: "${tmpOutputAddress}" = "${String(tmpExistingValue).substring(0, 100)}"`]
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If the operation was triggered programmatically (OperationState has pre-seeded values),
|
|
52
|
+
// auto-resolve using the DefaultValue so the whole chain runs without pausing.
|
|
53
|
+
// In interactive mode (empty OperationState), pause for user input.
|
|
54
|
+
let tmpIsProgrammatic = pExecutionContext.OperationState
|
|
55
|
+
&& Object.keys(pExecutionContext.OperationState).length > 0;
|
|
56
|
+
let tmpDefaultValue = pResolvedSettings.DefaultValue
|
|
57
|
+
|| (pResolvedSettings.InputSchema && pResolvedSettings.InputSchema.Default !== undefined
|
|
58
|
+
? String(pResolvedSettings.InputSchema.Default) : undefined);
|
|
59
|
+
if (tmpIsProgrammatic && tmpDefaultValue !== undefined && tmpDefaultValue !== null && tmpDefaultValue !== '')
|
|
60
|
+
{
|
|
61
|
+
return fCallback(null, {
|
|
62
|
+
EventToFire: 'complete',
|
|
63
|
+
Outputs: { InputValue: tmpDefaultValue },
|
|
64
|
+
Log: [`Auto-resolved from default: "${tmpOutputAddress}" = "${String(tmpDefaultValue).substring(0, 100)}"`]
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// For optional fields with no default in programmatic mode, pass empty string
|
|
69
|
+
let tmpIsOptional = pResolvedSettings.InputSchema
|
|
70
|
+
&& pResolvedSettings.InputSchema.Required === false;
|
|
71
|
+
if (tmpIsProgrammatic && tmpIsOptional)
|
|
72
|
+
{
|
|
73
|
+
return fCallback(null, {
|
|
74
|
+
EventToFire: 'complete',
|
|
75
|
+
Outputs: { InputValue: '' },
|
|
76
|
+
Log: [`Auto-resolved optional field: "${tmpOutputAddress}" = "" (no value provided)`]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No pre-seeded value and no default — pause and wait for interactive input
|
|
33
81
|
// The ExecutionEngine will set the run to WaitingForInput status
|
|
82
|
+
let tmpOptions = pResolvedSettings.Options || '';
|
|
83
|
+
if (Array.isArray(tmpOptions))
|
|
84
|
+
{
|
|
85
|
+
tmpOptions = JSON.stringify(tmpOptions);
|
|
86
|
+
}
|
|
87
|
+
|
|
34
88
|
return fCallback(null, {
|
|
35
89
|
WaitingForInput: true,
|
|
36
90
|
PromptMessage: tmpPromptMessage,
|
|
37
91
|
OutputAddress: tmpOutputAddress,
|
|
38
92
|
InputType: pResolvedSettings.InputType || 'text',
|
|
39
93
|
DefaultValue: pResolvedSettings.DefaultValue || '',
|
|
40
|
-
Options:
|
|
94
|
+
Options: tmpOptions,
|
|
41
95
|
Outputs: {},
|
|
42
96
|
Log: [`Waiting for input: "${tmpPromptMessage}" (-> ${tmpOutputAddress})`]
|
|
43
97
|
});
|
|
@@ -2055,6 +2055,21 @@ class UltravisorAPIServer extends libPictService
|
|
|
2055
2055
|
return;
|
|
2056
2056
|
}
|
|
2057
2057
|
|
|
2058
|
+
// Diagnostic: log what we RECEIVED from the client before forwarding
|
|
2059
|
+
// to registerBeacon. If the client sent HostID but we're storing null,
|
|
2060
|
+
// this log line pins down exactly where the drop happens (client vs
|
|
2061
|
+
// server). Gated on LogNoisiness>=2 to stay quiet in production.
|
|
2062
|
+
let tmpNoisy = (this.fable && this.fable.LogNoisiness) || 0;
|
|
2063
|
+
if (tmpNoisy >= 2)
|
|
2064
|
+
{
|
|
2065
|
+
this.log.info(`[WSRegister] received from client: Name=${pData.Name} HostID=${pData.HostID || '(none)'} SharedMounts=${JSON.stringify(pData.SharedMounts || [])} Ops=${(pData.Operations || []).length}`);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// IMPORTANT: this enumeration must include every field the coordinator
|
|
2069
|
+
// cares about, including HostID and SharedMounts (used by the shared-fs
|
|
2070
|
+
// reachability auto-detect). Forgetting to forward a field here means
|
|
2071
|
+
// the WebSocket-registered beacon record will have that field set to
|
|
2072
|
+
// null/empty in the coordinator, even though the client sent the value.
|
|
2058
2073
|
let tmpBeacon = tmpCoordinator.registerBeacon({
|
|
2059
2074
|
Name: pData.Name,
|
|
2060
2075
|
Capabilities: pData.Capabilities,
|
|
@@ -2063,9 +2078,17 @@ class UltravisorAPIServer extends libPictService
|
|
|
2063
2078
|
MaxConcurrent: pData.MaxConcurrent,
|
|
2064
2079
|
Tags: pData.Tags,
|
|
2065
2080
|
Contexts: pData.Contexts,
|
|
2066
|
-
BindAddresses: pData.BindAddresses
|
|
2081
|
+
BindAddresses: pData.BindAddresses,
|
|
2082
|
+
HostID: pData.HostID,
|
|
2083
|
+
SharedMounts: pData.SharedMounts
|
|
2067
2084
|
});
|
|
2068
2085
|
|
|
2086
|
+
// Diagnostic: confirm what was actually STORED on the beacon record.
|
|
2087
|
+
if (tmpNoisy >= 2)
|
|
2088
|
+
{
|
|
2089
|
+
this.log.info(`[WSRegister] stored beacon ${tmpBeacon.BeaconID}: HostID=${tmpBeacon.HostID || '(null)'} SharedMounts=${JSON.stringify(tmpBeacon.SharedMounts || [])}`);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2069
2092
|
pWebSocket._BeaconID = tmpBeacon.BeaconID;
|
|
2070
2093
|
this._BeaconWebSockets[tmpBeacon.BeaconID] = pWebSocket;
|
|
2071
2094
|
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the UltravisorBeaconReachability service, specifically the
|
|
3
|
+
* shared-fs reachability strategy (findSharedFsPeer + resolveStrategy).
|
|
4
|
+
*
|
|
5
|
+
* These tests exist to catch regressions in the logic that decides whether
|
|
6
|
+
* two beacons can skip the HTTP file-transfer layer because they share a
|
|
7
|
+
* filesystem on the same host. The shared-fs optimization is nearly invisible
|
|
8
|
+
* when it works (you just see faster thumbnails) and almost invisible when it
|
|
9
|
+
* silently stops working (you see slower thumbnails and a lot more bandwidth).
|
|
10
|
+
* The only way to catch regressions early is to assert the branch behavior
|
|
11
|
+
* of `findSharedFsPeer` and `resolveStrategy` directly, with mocked coordinator
|
|
12
|
+
* state standing in for a real running Ultravisor.
|
|
13
|
+
*
|
|
14
|
+
* Test layout:
|
|
15
|
+
*
|
|
16
|
+
* findSharedFsPeer
|
|
17
|
+
* - positive match: two beacons, same host, overlapping mount → MATCH
|
|
18
|
+
* - negative: source beacon missing HostID (legacy) → null
|
|
19
|
+
* - negative: source beacon has empty SharedMounts → null
|
|
20
|
+
* - negative: source beacon missing entirely → null
|
|
21
|
+
* - negative: no coordinator service registered → null
|
|
22
|
+
* - negative: only the source beacon exists (no peers) → null
|
|
23
|
+
* - negative: peer on different host → null
|
|
24
|
+
* - negative: peer on same host but no overlapping MountID → null
|
|
25
|
+
* - negative: peer is Offline → null
|
|
26
|
+
* - positive: multiple peers, one matches → MATCH (first match wins)
|
|
27
|
+
*
|
|
28
|
+
* resolveStrategy
|
|
29
|
+
* - local (same beacon) → Strategy: local
|
|
30
|
+
* - shared-fs (same host, overlapping mount) → Strategy: shared-fs + root
|
|
31
|
+
* - direct (different host, reachable matrix) → Strategy: direct
|
|
32
|
+
* - proxy fallback (matrix says unreachable/untested) → Strategy: proxy
|
|
33
|
+
*
|
|
34
|
+
* _findSharedMount
|
|
35
|
+
* - helper-level tests for the pure mount-overlap logic
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const libPict = require('pict');
|
|
39
|
+
const libUltravisorBeaconReachability = require('../source/services/Ultravisor-Beacon-Reachability.cjs');
|
|
40
|
+
|
|
41
|
+
var Chai = require('chai');
|
|
42
|
+
var Expect = Chai.expect;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a Pict instance with a MOCK BeaconCoordinator and the real
|
|
46
|
+
* Reachability service. The coordinator mock exposes the same two methods
|
|
47
|
+
* (getBeacon, listBeacons) that Reachability consumes, so we can construct
|
|
48
|
+
* arbitrary beacon topologies without spinning up the real coordinator.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object<string, object>} pBeacons - Keyed by BeaconID
|
|
51
|
+
* @returns {{ fable, reachability, coordinator }}
|
|
52
|
+
*/
|
|
53
|
+
function _buildTestHarness(pBeacons)
|
|
54
|
+
{
|
|
55
|
+
let tmpFable = new libPict(
|
|
56
|
+
{
|
|
57
|
+
Product: 'Ultravisor-Test-Reachability',
|
|
58
|
+
LogLevel: 5
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Mock coordinator — just enough surface area for Reachability to work.
|
|
62
|
+
let tmpMockCoordinator =
|
|
63
|
+
{
|
|
64
|
+
serviceType: 'UltravisorBeaconCoordinator',
|
|
65
|
+
Hash: 'MockCoordinator',
|
|
66
|
+
_Beacons: pBeacons || {},
|
|
67
|
+
getBeacon: function (pBeaconID)
|
|
68
|
+
{
|
|
69
|
+
return this._Beacons[pBeaconID] || null;
|
|
70
|
+
},
|
|
71
|
+
listBeacons: function ()
|
|
72
|
+
{
|
|
73
|
+
return Object.values(this._Beacons);
|
|
74
|
+
},
|
|
75
|
+
setBeacons: function (pNewBeacons)
|
|
76
|
+
{
|
|
77
|
+
this._Beacons = pNewBeacons || {};
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Register the mock under the same name Reachability expects.
|
|
82
|
+
if (!tmpFable.servicesMap['UltravisorBeaconCoordinator'])
|
|
83
|
+
{
|
|
84
|
+
tmpFable.servicesMap['UltravisorBeaconCoordinator'] = {};
|
|
85
|
+
}
|
|
86
|
+
tmpFable.servicesMap['UltravisorBeaconCoordinator'][tmpMockCoordinator.Hash] = tmpMockCoordinator;
|
|
87
|
+
|
|
88
|
+
// Instantiate the real Reachability service.
|
|
89
|
+
tmpFable.addAndInstantiateServiceTypeIfNotExists('UltravisorBeaconReachability', libUltravisorBeaconReachability);
|
|
90
|
+
let tmpReachability = Object.values(tmpFable.servicesMap['UltravisorBeaconReachability'])[0];
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
fable: tmpFable,
|
|
94
|
+
reachability: tmpReachability,
|
|
95
|
+
coordinator: tmpMockCoordinator
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Helper: build a fake beacon record. Anything not specified defaults to the
|
|
101
|
+
* "typical online beacon on host-alpha with one shared mount" shape.
|
|
102
|
+
*/
|
|
103
|
+
function _beacon(pOverrides)
|
|
104
|
+
{
|
|
105
|
+
let tmpBase =
|
|
106
|
+
{
|
|
107
|
+
BeaconID: 'bcn-default',
|
|
108
|
+
Name: 'default',
|
|
109
|
+
Status: 'Online',
|
|
110
|
+
HostID: 'host-alpha',
|
|
111
|
+
SharedMounts: [{ MountID: 'mnt-1', Root: '/data' }],
|
|
112
|
+
Contexts: { File: { BasePath: '/data', BaseURL: '/content/' } },
|
|
113
|
+
BindAddresses: [{ IP: '10.0.0.2', Port: 7777, Protocol: 'http' }]
|
|
114
|
+
};
|
|
115
|
+
return Object.assign(tmpBase, pOverrides || {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
suite
|
|
119
|
+
(
|
|
120
|
+
'Ultravisor Beacon Reachability',
|
|
121
|
+
function ()
|
|
122
|
+
{
|
|
123
|
+
// ================================================================
|
|
124
|
+
// findSharedFsPeer
|
|
125
|
+
// ================================================================
|
|
126
|
+
suite
|
|
127
|
+
(
|
|
128
|
+
'findSharedFsPeer',
|
|
129
|
+
function ()
|
|
130
|
+
{
|
|
131
|
+
test
|
|
132
|
+
(
|
|
133
|
+
'returns null when the coordinator service is not registered',
|
|
134
|
+
function ()
|
|
135
|
+
{
|
|
136
|
+
let tmpFable = new libPict({ Product: 'Ultravisor-Test-Reachability', LogLevel: 5 });
|
|
137
|
+
// Note: NO coordinator registered — simulate a broken test harness
|
|
138
|
+
tmpFable.addAndInstantiateServiceTypeIfNotExists('UltravisorBeaconReachability', libUltravisorBeaconReachability);
|
|
139
|
+
let tmpReachability = Object.values(tmpFable.servicesMap['UltravisorBeaconReachability'])[0];
|
|
140
|
+
|
|
141
|
+
Expect(tmpReachability.findSharedFsPeer('bcn-whatever')).to.equal(null);
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
test
|
|
146
|
+
(
|
|
147
|
+
'returns null when the source beacon is not in the coordinator registry',
|
|
148
|
+
function ()
|
|
149
|
+
{
|
|
150
|
+
let tmpHarness = _buildTestHarness({});
|
|
151
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-ghost')).to.equal(null);
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
test
|
|
156
|
+
(
|
|
157
|
+
'returns null when the source beacon has no HostID (legacy beacon)',
|
|
158
|
+
function ()
|
|
159
|
+
{
|
|
160
|
+
let tmpHarness = _buildTestHarness(
|
|
161
|
+
{
|
|
162
|
+
'bcn-legacy': _beacon({ BeaconID: 'bcn-legacy', HostID: null }),
|
|
163
|
+
'bcn-peer': _beacon({ BeaconID: 'bcn-peer', HostID: 'host-alpha' })
|
|
164
|
+
});
|
|
165
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-legacy')).to.equal(null);
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
test
|
|
170
|
+
(
|
|
171
|
+
'returns null when the source beacon has an empty SharedMounts array',
|
|
172
|
+
function ()
|
|
173
|
+
{
|
|
174
|
+
let tmpHarness = _buildTestHarness(
|
|
175
|
+
{
|
|
176
|
+
'bcn-empty': _beacon({ BeaconID: 'bcn-empty', SharedMounts: [] }),
|
|
177
|
+
'bcn-peer': _beacon({ BeaconID: 'bcn-peer' })
|
|
178
|
+
});
|
|
179
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-empty')).to.equal(null);
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
test
|
|
184
|
+
(
|
|
185
|
+
'returns null when only the source beacon is registered (no peers at all)',
|
|
186
|
+
function ()
|
|
187
|
+
{
|
|
188
|
+
let tmpHarness = _buildTestHarness(
|
|
189
|
+
{
|
|
190
|
+
'bcn-lonely': _beacon({ BeaconID: 'bcn-lonely' })
|
|
191
|
+
});
|
|
192
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-lonely')).to.equal(null);
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
test
|
|
197
|
+
(
|
|
198
|
+
'returns null when the peer is on a different host',
|
|
199
|
+
function ()
|
|
200
|
+
{
|
|
201
|
+
let tmpHarness = _buildTestHarness(
|
|
202
|
+
{
|
|
203
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
|
|
204
|
+
'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
|
|
205
|
+
});
|
|
206
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
test
|
|
211
|
+
(
|
|
212
|
+
'returns null when the peer shares a host but has no overlapping MountID',
|
|
213
|
+
function ()
|
|
214
|
+
{
|
|
215
|
+
let tmpHarness = _buildTestHarness(
|
|
216
|
+
{
|
|
217
|
+
'bcn-source': _beacon(
|
|
218
|
+
{
|
|
219
|
+
BeaconID: 'bcn-source',
|
|
220
|
+
SharedMounts: [{ MountID: 'mnt-alpha', Root: '/data-alpha' }]
|
|
221
|
+
}),
|
|
222
|
+
'bcn-peer': _beacon(
|
|
223
|
+
{
|
|
224
|
+
BeaconID: 'bcn-peer',
|
|
225
|
+
SharedMounts: [{ MountID: 'mnt-beta', Root: '/data-beta' }]
|
|
226
|
+
})
|
|
227
|
+
});
|
|
228
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
test
|
|
233
|
+
(
|
|
234
|
+
'returns null when the only matching peer is Offline',
|
|
235
|
+
function ()
|
|
236
|
+
{
|
|
237
|
+
let tmpHarness = _buildTestHarness(
|
|
238
|
+
{
|
|
239
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
|
|
240
|
+
'bcn-dead': _beacon({ BeaconID: 'bcn-dead', Status: 'Offline' })
|
|
241
|
+
});
|
|
242
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
test
|
|
247
|
+
(
|
|
248
|
+
'returns a MATCH when a peer shares host and MountID (happy path)',
|
|
249
|
+
function ()
|
|
250
|
+
{
|
|
251
|
+
let tmpHarness = _buildTestHarness(
|
|
252
|
+
{
|
|
253
|
+
'bcn-retold-remote': _beacon({ BeaconID: 'bcn-retold-remote' }),
|
|
254
|
+
'bcn-orator-conversion': _beacon({ BeaconID: 'bcn-orator-conversion' })
|
|
255
|
+
});
|
|
256
|
+
let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-retold-remote');
|
|
257
|
+
Expect(tmpResult).to.not.equal(null);
|
|
258
|
+
Expect(tmpResult.Peer.BeaconID).to.equal('bcn-orator-conversion');
|
|
259
|
+
Expect(tmpResult.Mount.MountID).to.equal('mnt-1');
|
|
260
|
+
Expect(tmpResult.Mount.Root).to.equal('/data');
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
test
|
|
265
|
+
(
|
|
266
|
+
'returns the first MATCH when multiple peers share host+mount',
|
|
267
|
+
function ()
|
|
268
|
+
{
|
|
269
|
+
let tmpHarness = _buildTestHarness(
|
|
270
|
+
{
|
|
271
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
|
|
272
|
+
'bcn-peer-1': _beacon({ BeaconID: 'bcn-peer-1' }),
|
|
273
|
+
'bcn-peer-2': _beacon({ BeaconID: 'bcn-peer-2' })
|
|
274
|
+
});
|
|
275
|
+
let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-source');
|
|
276
|
+
Expect(tmpResult).to.not.equal(null);
|
|
277
|
+
// Whichever peer is first in listBeacons iteration order wins — we
|
|
278
|
+
// don't care which; we just care that SOMETHING matched.
|
|
279
|
+
Expect(['bcn-peer-1', 'bcn-peer-2']).to.include(tmpResult.Peer.BeaconID);
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
test
|
|
284
|
+
(
|
|
285
|
+
'does not match the source beacon against itself',
|
|
286
|
+
function ()
|
|
287
|
+
{
|
|
288
|
+
let tmpHarness = _buildTestHarness(
|
|
289
|
+
{
|
|
290
|
+
'bcn-solo': _beacon({ BeaconID: 'bcn-solo' })
|
|
291
|
+
});
|
|
292
|
+
// With only the source beacon in the registry, no match.
|
|
293
|
+
Expect(tmpHarness.reachability.findSharedFsPeer('bcn-solo')).to.equal(null);
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
test
|
|
298
|
+
(
|
|
299
|
+
'finds a peer when source has multiple mounts and at least one overlaps',
|
|
300
|
+
function ()
|
|
301
|
+
{
|
|
302
|
+
let tmpHarness = _buildTestHarness(
|
|
303
|
+
{
|
|
304
|
+
'bcn-source': _beacon(
|
|
305
|
+
{
|
|
306
|
+
BeaconID: 'bcn-source',
|
|
307
|
+
SharedMounts: [
|
|
308
|
+
{ MountID: 'mnt-content', Root: '/media' },
|
|
309
|
+
{ MountID: 'mnt-cache', Root: '/cache' },
|
|
310
|
+
{ MountID: 'mnt-config', Root: '/config' }
|
|
311
|
+
]
|
|
312
|
+
}),
|
|
313
|
+
'bcn-peer': _beacon(
|
|
314
|
+
{
|
|
315
|
+
BeaconID: 'bcn-peer',
|
|
316
|
+
SharedMounts: [
|
|
317
|
+
{ MountID: 'mnt-cache', Root: '/cache' }
|
|
318
|
+
]
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-source');
|
|
322
|
+
Expect(tmpResult).to.not.equal(null);
|
|
323
|
+
Expect(tmpResult.Mount.MountID).to.equal('mnt-cache');
|
|
324
|
+
Expect(tmpResult.Mount.Root).to.equal('/cache');
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// ================================================================
|
|
331
|
+
// resolveStrategy
|
|
332
|
+
// ================================================================
|
|
333
|
+
suite
|
|
334
|
+
(
|
|
335
|
+
'resolveStrategy',
|
|
336
|
+
function ()
|
|
337
|
+
{
|
|
338
|
+
test
|
|
339
|
+
(
|
|
340
|
+
'returns Strategy=local when source and requesting beacon are the same',
|
|
341
|
+
function ()
|
|
342
|
+
{
|
|
343
|
+
let tmpHarness = _buildTestHarness(
|
|
344
|
+
{
|
|
345
|
+
'bcn-only': _beacon({ BeaconID: 'bcn-only' })
|
|
346
|
+
});
|
|
347
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-only', 'bcn-only');
|
|
348
|
+
Expect(tmpResult.Strategy).to.equal('local');
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
test
|
|
353
|
+
(
|
|
354
|
+
'returns Strategy=shared-fs when beacons share host+mount',
|
|
355
|
+
function ()
|
|
356
|
+
{
|
|
357
|
+
let tmpHarness = _buildTestHarness(
|
|
358
|
+
{
|
|
359
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
|
|
360
|
+
'bcn-consumer': _beacon({ BeaconID: 'bcn-consumer' })
|
|
361
|
+
});
|
|
362
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-consumer');
|
|
363
|
+
Expect(tmpResult.Strategy).to.equal('shared-fs');
|
|
364
|
+
Expect(tmpResult.SharedMountRoot).to.equal('/data');
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
test
|
|
369
|
+
(
|
|
370
|
+
'returns Strategy=proxy when beacons are on different hosts and reachability is untested',
|
|
371
|
+
function ()
|
|
372
|
+
{
|
|
373
|
+
let tmpHarness = _buildTestHarness(
|
|
374
|
+
{
|
|
375
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
|
|
376
|
+
'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
|
|
377
|
+
});
|
|
378
|
+
// Untested matrix → falls through to proxy
|
|
379
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-remote');
|
|
380
|
+
Expect(tmpResult.Strategy).to.equal('proxy');
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
test
|
|
385
|
+
(
|
|
386
|
+
'returns Strategy=direct when the matrix says the pair is reachable',
|
|
387
|
+
function ()
|
|
388
|
+
{
|
|
389
|
+
let tmpHarness = _buildTestHarness(
|
|
390
|
+
{
|
|
391
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
|
|
392
|
+
'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
|
|
393
|
+
});
|
|
394
|
+
// Stub the reachability matrix to say these two CAN reach each other
|
|
395
|
+
tmpHarness.reachability.getReachability = function ()
|
|
396
|
+
{
|
|
397
|
+
return { Status: 'reachable' };
|
|
398
|
+
};
|
|
399
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-remote');
|
|
400
|
+
Expect(tmpResult.Strategy).to.equal('direct');
|
|
401
|
+
Expect(tmpResult.DirectURL).to.be.a('string');
|
|
402
|
+
}
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
test
|
|
406
|
+
(
|
|
407
|
+
'returns Strategy=proxy when the source beacon does not exist in the registry',
|
|
408
|
+
function ()
|
|
409
|
+
{
|
|
410
|
+
let tmpHarness = _buildTestHarness(
|
|
411
|
+
{
|
|
412
|
+
'bcn-requestor': _beacon({ BeaconID: 'bcn-requestor' })
|
|
413
|
+
});
|
|
414
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-vanished', 'bcn-requestor');
|
|
415
|
+
Expect(tmpResult.Strategy).to.equal('proxy');
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
test
|
|
420
|
+
(
|
|
421
|
+
'prefers shared-fs over direct when both are possible',
|
|
422
|
+
function ()
|
|
423
|
+
{
|
|
424
|
+
let tmpHarness = _buildTestHarness(
|
|
425
|
+
{
|
|
426
|
+
'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
|
|
427
|
+
'bcn-consumer': _beacon({ BeaconID: 'bcn-consumer' })
|
|
428
|
+
});
|
|
429
|
+
// Even if the matrix says they're reachable via HTTP, shared-fs
|
|
430
|
+
// should still win because it's cheaper.
|
|
431
|
+
tmpHarness.reachability.getReachability = function ()
|
|
432
|
+
{
|
|
433
|
+
return { Status: 'reachable' };
|
|
434
|
+
};
|
|
435
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-consumer');
|
|
436
|
+
Expect(tmpResult.Strategy).to.equal('shared-fs');
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
test
|
|
441
|
+
(
|
|
442
|
+
'falls back to direct/proxy when one of the beacons is a legacy (no HostID) beacon',
|
|
443
|
+
function ()
|
|
444
|
+
{
|
|
445
|
+
let tmpHarness = _buildTestHarness(
|
|
446
|
+
{
|
|
447
|
+
'bcn-legacy': _beacon({ BeaconID: 'bcn-legacy', HostID: null }),
|
|
448
|
+
'bcn-modern': _beacon({ BeaconID: 'bcn-modern' })
|
|
449
|
+
});
|
|
450
|
+
// A legacy beacon can't participate in shared-fs — should fall
|
|
451
|
+
// through to the matrix-based direct/proxy decision.
|
|
452
|
+
let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-legacy', 'bcn-modern');
|
|
453
|
+
Expect(tmpResult.Strategy).to.not.equal('shared-fs');
|
|
454
|
+
Expect(['direct', 'proxy']).to.include(tmpResult.Strategy);
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// ================================================================
|
|
461
|
+
// _findSharedMount (helper)
|
|
462
|
+
// ================================================================
|
|
463
|
+
suite
|
|
464
|
+
(
|
|
465
|
+
'_findSharedMount',
|
|
466
|
+
function ()
|
|
467
|
+
{
|
|
468
|
+
test
|
|
469
|
+
(
|
|
470
|
+
'returns null for empty or missing arrays',
|
|
471
|
+
function ()
|
|
472
|
+
{
|
|
473
|
+
let tmpHarness = _buildTestHarness({});
|
|
474
|
+
let tmpR = tmpHarness.reachability;
|
|
475
|
+
Expect(tmpR._findSharedMount(null, null)).to.equal(null);
|
|
476
|
+
Expect(tmpR._findSharedMount(undefined, undefined)).to.equal(null);
|
|
477
|
+
Expect(tmpR._findSharedMount([], [])).to.equal(null);
|
|
478
|
+
Expect(tmpR._findSharedMount([{ MountID: 'x' }], [])).to.equal(null);
|
|
479
|
+
Expect(tmpR._findSharedMount([], [{ MountID: 'x' }])).to.equal(null);
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
test
|
|
484
|
+
(
|
|
485
|
+
'returns the first matching mount from the source side',
|
|
486
|
+
function ()
|
|
487
|
+
{
|
|
488
|
+
let tmpHarness = _buildTestHarness({});
|
|
489
|
+
let tmpMatch = tmpHarness.reachability._findSharedMount(
|
|
490
|
+
[
|
|
491
|
+
{ MountID: 'a', Root: '/root-a' },
|
|
492
|
+
{ MountID: 'b', Root: '/root-b' }
|
|
493
|
+
],
|
|
494
|
+
[
|
|
495
|
+
{ MountID: 'b', Root: '/root-b-as-seen-by-peer' }
|
|
496
|
+
]);
|
|
497
|
+
Expect(tmpMatch).to.not.equal(null);
|
|
498
|
+
Expect(tmpMatch.MountID).to.equal('b');
|
|
499
|
+
// Source-side root wins — which matters because the dispatcher
|
|
500
|
+
// uses this Root to build the LocalPath for the requesting side.
|
|
501
|
+
Expect(tmpMatch.Root).to.equal('/root-b');
|
|
502
|
+
}
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
test
|
|
506
|
+
(
|
|
507
|
+
'skips entries that have no MountID',
|
|
508
|
+
function ()
|
|
509
|
+
{
|
|
510
|
+
let tmpHarness = _buildTestHarness({});
|
|
511
|
+
Expect(tmpHarness.reachability._findSharedMount(
|
|
512
|
+
[{ Root: '/a' }, { MountID: 'c', Root: '/c' }],
|
|
513
|
+
[{ MountID: 'c', Root: '/c' }]
|
|
514
|
+
).MountID).to.equal('c');
|
|
515
|
+
}
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
);
|
package/test/Ultravisor_tests.js
CHANGED
|
@@ -190,7 +190,7 @@ suite
|
|
|
190
190
|
Expect(tmpInstance.definition.Hash).to.equal('read-file');
|
|
191
191
|
|
|
192
192
|
let tmpDefs = tmpRegistry.listDefinitions();
|
|
193
|
-
Expect(tmpDefs.length).to.equal(
|
|
193
|
+
Expect(tmpDefs.length).to.equal(56);
|
|
194
194
|
|
|
195
195
|
// Verify all registered definitions have Capability, Action, and Tier
|
|
196
196
|
for (let i = 0; i < tmpDefs.length; i++)
|
|
@@ -1806,7 +1806,7 @@ suite
|
|
|
1806
1806
|
let tmpBuiltInConfigs = require('../source/services/tasks/Ultravisor-BuiltIn-TaskConfigs.cjs');
|
|
1807
1807
|
let tmpCount = tmpRegistry.registerTaskTypesFromConfigArray(tmpBuiltInConfigs);
|
|
1808
1808
|
|
|
1809
|
-
Expect(tmpCount).to.equal(
|
|
1809
|
+
Expect(tmpCount).to.equal(56);
|
|
1810
1810
|
|
|
1811
1811
|
// Spot-check a few
|
|
1812
1812
|
Expect(tmpRegistry.hasTaskType('error-message')).to.equal(true);
|
|
@@ -1991,7 +1991,7 @@ suite
|
|
|
1991
1991
|
|
|
1992
1992
|
// Configs already registered by createTestFable — verify all present
|
|
1993
1993
|
let tmpDefs = tmpRegistry.listDefinitions();
|
|
1994
|
-
Expect(tmpDefs.length).to.equal(
|
|
1994
|
+
Expect(tmpDefs.length).to.equal(56);
|
|
1995
1995
|
}
|
|
1996
1996
|
);
|
|
1997
1997
|
}
|
|
@@ -3877,7 +3877,7 @@ suite
|
|
|
3877
3877
|
|
|
3878
3878
|
// Verify WaitingTasks has the dispatch node
|
|
3879
3879
|
Expect(pContext.WaitingTasks['dispatch-1']).to.not.equal(undefined);
|
|
3880
|
-
Expect(pContext.WaitingTasks['dispatch-1'].ResumeEventName).to.equal('
|
|
3880
|
+
Expect(pContext.WaitingTasks['dispatch-1'].ResumeEventName).to.equal('complete');
|
|
3881
3881
|
|
|
3882
3882
|
// Verify a work item was enqueued
|
|
3883
3883
|
let tmpWorkItems = tmpCoordinator.listWorkItems();
|