// Copyright Keysight Technologies 2012-2019 // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at http://mozilla.org/MPL/2.0/. using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.IO; using System.Xml.Serialization; using System.Xml.Linq; using OpenTap.Diagnostic; namespace OpenTap.Plugins.BasicSteps { [Browsable(false)] [Display("Test Plan Reference", "References a test plan from an external file directly, without having to store the test plan as steps in the current test plan.", "Flow Control")] [AllowAnyChild] public class TestPlanReference : TestStep { /// /// Mapping between step GUIDs. Old version -> New Version. /// public class GuidMapping { public Guid Guid1 { get; set; } public Guid Guid2 { get; set; } } /// /// This is the list of path of test plan loaded and used to prevent recursive TestPlan references. /// [ThreadStatic] static HashSet referencedPlanPaths; /// Gets or sets if the loaded test steps should be hidden from the user. [Display("Hide Steps", Order: 0, Description: "Set if the steps should run hidden (isolated) or if they should be loaded into the test plan.")] private bool HideSteps { get; set; } ITestStepParent parent; // The PlanDir of 'this' should be ignored when calculating Filepath, so the MacroString context is set to the parent. [XmlIgnore] public override ITestStepParent Parent { get => parent; set { Filepath.Context = value; parent = value; } } MacroString filepath = new MacroString(); string currentlyLoaded; public bool CanOpenFile => File.Exists(filepath.Expand()); [Display("Referenced Plan", Order: 0, Description: "A file path pointing to a test plan which will be loaded as read-only test steps.")] [Browsable(true)] [FilePath(FilePathAttribute.BehaviorChoice.Open, "TapPlan")] [DeserializeOrder(1.0)] [Unsweepable] public MacroString Filepath { get => filepath; set { filepath = value; filepath.Context = this; try { var rp = TapSerializer.GetObjectDeserializer(this)?.ReadPath; if (rp != null) rp = System.IO.Path.GetDirectoryName(rp); var fp = filepath.Expand(testPlanDir: rp); if (currentlyLoaded != fp) { LoadTestPlan(); currentlyLoaded = fp; } } catch { } } } [Browsable(false)] public string Hash { get; set; } [AnnotationIgnore] public string Path => Filepath.Expand(); bool isExpandingPlanDir = false; [MetaData(macroName: "TestPlanDir")] [AnnotationIgnore] public string PlanDir { get { if (isExpandingPlanDir) return null; isExpandingPlanDir = true; try { var exp = new MacroString(this) { Text = Filepath.Text }.Expand(); if (string.IsNullOrWhiteSpace(exp)) return ""; var path = System.IO.Path.GetFullPath(exp); return string.IsNullOrWhiteSpace(path) ? "" : System.IO.Path.GetDirectoryName(path); } catch { return ""; } finally { isExpandingPlanDir = false; } } } [XmlIgnore] [Browsable(false)] [AnnotationIgnore] public new TestStepList ChildTestSteps { get { return base.ChildTestSteps; } set { base.ChildTestSteps = value; } } public TestPlanReference() { ChildTestSteps.IsReadOnly = true; Filepath = new MacroString(this); Rules.Add(() => (string.IsNullOrWhiteSpace(Filepath) || File.Exists(Filepath)), "File does not exist.", nameof(Filepath)); StepMapping = new List(); } public override void PrePlanRun() { base.PrePlanRun(); if (string.IsNullOrWhiteSpace(Filepath)) throw new OperationCanceledException(string.Format("Execution aborted by {0}. No test plan configured.", Name)); // Detect if the plan was not loaded or if the path has been changed since loading it. // Note, there is some funky things related to TestPlanDir that are not checked but 99% of use-cases are. var expandedPath = filepath.Text; if (loadedPlanPath != expandedPath && File.Exists(expandedPath) && HideSteps) { LoadTestPlan(); } if (loadedPlanPath != expandedPath) throw new OperationCanceledException(string.Format("Execution aborted by {0}. Test plan not loaded.", Name)); } class SubPlanResultListener : ResultListener { readonly ResultSource proxy; public SubPlanResultListener(ResultSource proxy) => this.proxy = proxy; public override void OnResultPublished(Guid stepRunId, ResultTable result) { base.OnResultPublished(stepRunId, result); proxy.PublishTable(result); } } class LogForwardingTraceListener : ILogListener { readonly ILogContext2 forwardTo; public LogForwardingTraceListener(ILogContext2 forwardTo) => this.forwardTo = forwardTo; public void EventsLogged(IEnumerable Events) { foreach (var evt in Events) { if (evt.Source == "TestPlan" || evt.Source == "N/A") if(evt.EventType != (int)LogEventType.Error) continue; forwardTo.AddEvent(evt); } } public void Flush() { } } public override void Run() { if (HideSteps) { LogForwardingTraceListener forwarder = null; var xml = plan.SerializeToString(); if(OpenTap.Log.Context is ILogContext2 ctx2) forwarder = new LogForwardingTraceListener(ctx2); using (Session.Create()) { var plan2 = Utils.DeserializeFromString(xml); plan2.PrintTestPlanRunSummary = false; if(forwarder != null) OpenTap.Log.AddListener(forwarder); var subRun = plan2.Execute(new IResultListener[] {new SubPlanResultListener(Results)}); UpgradeVerdict(subRun.Verdict); } } else { foreach (var run in RunChildSteps()) UpgradeVerdict(run.Verdict); } } [ThreadStatic] static List CurrentMappings; static readonly Memorizer dict = new Memorizer(p => { return XDocument.Load(p, LoadOptions.SetLineInfo); }) { // Validator is to reload the file if it has been changed. // Assuming it is much faster to check file write time than to read and parse it. Testing has verified this. Validator = str => { var file = new FileInfo(str); return $"{file.LastWriteTime} {file.Length}"; }, MaxNumberOfElements = 100 }; static XDocument ReadCachedXmlFile(string path) { var cached = dict.Invoke(path); // The deserializer may modify the XDocument class so it must be cloned by constructing a new XDocument (this causes a deep clone to be made). return new XDocument(cached); } internal TestPlan plan; string loadedPlanPath; void UpdateStep() { if (CurrentMappings == null) CurrentMappings = new List(); // Load GUID mappings which is every two GUIDS between and var mapping = new Dictionary(); var allMapping = this.mapping.Concat(CurrentMappings).ToList(); foreach (var mapItem in allMapping) { mapping[mapItem.Guid1] = mapItem.Guid2; } ITestStep parent = this; while(parent != null) { if(parent is TestPlanReference tpr) { foreach(var mp in tpr.mapping) { mapping[mp.Guid1] = mp.Guid2; } } parent = parent.Parent as ITestStep; } object testplandir = null; var currentSerializer = TapSerializer.GetObjectDeserializer(this); if (currentSerializer != null && currentSerializer.ReadPath != null) testplandir = System.IO.Path.GetDirectoryName(currentSerializer.ReadPath); var refPlanPath = Filepath.Expand(testPlanDir: testplandir as string); refPlanPath = refPlanPath.Replace('\\', '/'); if (!File.Exists(refPlanPath)) { Log.Warning("File does not exist: \"{0}\"", refPlanPath); return; } ChildTestSteps.Clear(); var prevc = CurrentMappings; try { try { if (referencedPlanPaths == null) referencedPlanPaths = new HashSet(); if (currentSerializer?.ReadPath == refPlanPath || referencedPlanPaths.Add(refPlanPath) == false) throw new Exception("Test plan reference is trying to load itself leading to recursive loop."); var newSerializer = new TapSerializer(); if (currentSerializer != null) newSerializer.GetSerializer().PreloadedValues.MergeInto(currentSerializer.GetSerializer().PreloadedValues); var ext = newSerializer.GetSerializer(); foreach (var e in ExternalParameters) { /* If the value can be converted to/from a string, do so */ if (StringConvertProvider.TryGetString(e.GetValue(plan), out var str)) { ext.PreloadedValues[e.Name] = str; } /* otherwise, write a null value to ensure the current value is not overwritten in the serializer. We can write it back later. */ else { ext.PreloadedValues[e.Name] = null; } } CurrentMappings = allMapping; loadedPlanPath = filepath.Text; var doc = ReadCachedXmlFile(refPlanPath); TestPlan tp = (TestPlan)newSerializer.Deserialize(doc, TypeData.FromType(typeof(TestPlan)), true, refPlanPath) ; plan = tp; using (var algo = System.Security.Cryptography.SHA1.Create()) { using (var ms = new MemoryStream()) { doc.Save(ms, SaveOptions.DisableFormatting); Hash = BitConverter.ToString(algo.ComputeHash(ms.ToArray()), 0, 8) .Replace("-", string.Empty); } } { /* write back any parameters that could not be converted to a string */ var newParameters = TypeData.GetTypeData(tp).GetMembers().OfType() .ToArray(); ILookup lookup = null; foreach (var par in newParameters) { if (par.GetValue(plan) == null) { lookup ??= ExternalParameters.ToLookup(m => m.Name); var prevParameter = lookup[par.Name].FirstOrDefault(); if (prevParameter != null) par.SetValue(plan, prevParameter.GetValue(plan)); } } ExternalParameters = newParameters; } var flatSteps = Utils.FlattenHeirarchy(tp.ChildTestSteps, x => x.ChildTestSteps); StepIdMapping = flatSteps.ToDictionary(x => x, x => x.Id); foreach (var step in flatSteps) { Guid id; if (mapping.TryGetValue(step.Id, out id)) step.Id = id; } if (HideSteps == false) { foreach (var item in tp.ChildTestSteps) ChildTestSteps.Add(item); foreach (var step in RecursivelyGetChildSteps(TestStepSearch.All)) { step.IsReadOnly = true; step.ChildTestSteps.IsReadOnly = true; step.OnPropertyChanged(""); } } if (currentSerializer == null) { // if currentSerializer is set, it means that we are loading a previously saved test plan reference. // Hence these things will be automatically set up. // otherwise we need to trasfer mixins and dynamic member values. // transfer mixins var thisType = TypeData.GetTypeData(this); var s = TypeData.GetTypeData(tp).GetMembers(); foreach (var member in TypeData.GetTypeData(tp).GetMembers()) { if (member is MixinMemberData mixinMember) { if (thisType.GetMember(member.Name) != null) continue; var mixin = mixinMember.Source; var mem = mixin.ToDynamicMember(thisType); if (mem == null) { if (mixin is IValidatingObject validating && validating.Error is string err && string.IsNullOrEmpty(err) == false) { Log.Error($"Unable to load mixin: {err}"); } else { Log.Error($"Unable to load mixin: {TypeData.GetTypeData(mixin)?.GetDisplayAttribute()?.Name ?? mixin.ToString()}"); } continue; } // transfer the value from the test plan instance to this instance. var value = member.GetValue(tp); DynamicMember.AddDynamicMember(this, mem); mem.SetValue(this, value); } } // transfer dynamic member values. foreach (var member in TypeData.GetTypeData(tp).GetMembers().Where(mem => mem is DynamicMember)) { if (member.HasAttribute()) continue; member.SetValue(this, member.GetValue(tp)); } } } finally { if (referencedPlanPaths != null) { referencedPlanPaths.Remove(refPlanPath); if (referencedPlanPaths.Count == 0) referencedPlanPaths = null; } CurrentMappings = prevc; } } catch (Exception ex) { Log.Error("Unable to read '{0}'.", Filepath.Text); Log.Error(ex); } } /// Used to determine if a step ID has changed from the time it was loaded to the time it is saved. Dictionary StepIdMapping { get; set; } public List mapping; [Browsable(false)] [AnnotationIgnore] public List StepMapping { get { if (StepIdMapping == null) return new List(); var subMappings = Utils.FlattenHeirarchy(ChildTestSteps, x => x.ChildTestSteps).OfType().SelectMany(x => (IEnumerable< KeyValuePair < ITestStep, Guid > >)x.StepIdMapping ?? Array.Empty>()).ToArray(); return StepIdMapping.Concat(subMappings).Select(x => new GuidMapping { Guid2 = x.Key.Id, Guid1 = x.Value }).Where(x => x.Guid1 != x.Guid2).ToList(); } set { mapping = value; } } [Browsable(true)] [Display("Load Test Plan", Order: 1, Description: "Load the selected test plan.")] [EnabledIf(nameof(CanOpenFile))] public void LoadTestPlan() => loadTestPlan(); public void loadTestPlan() { ChildTestSteps.Clear(); if (string.IsNullOrWhiteSpace(Filepath)) { ExternalParameters = Array.Empty(); return; } UpdateStep(); } string GetPath() { object testplandir = null; var currentSerializer = TapSerializer.GetObjectDeserializer(this); if (currentSerializer != null && currentSerializer.ReadPath != null) testplandir = System.IO.Path.GetDirectoryName(currentSerializer.ReadPath); var refPlanPath = Filepath.Expand(testPlanDir: testplandir as string); refPlanPath = refPlanPath.Replace('\\', '/'); return refPlanPath; } public bool anyStepsLoaded => ChildTestSteps.Any() && GetPath() is string path && File.Exists(path); [Display("Are you sure?")] class ConvertWarning { public enum ConvertOrCancel { Convert, Cancel } [Browsable(true)] [Layout(LayoutMode.FullRow | LayoutMode.WrapText)] public string Message { get; } [Layout(LayoutMode.FloatBottom | LayoutMode.FullRow)] [Submit] public ConvertOrCancel Response { set; get; } = ConvertOrCancel.Cancel; public ConvertWarning(bool multiSelect) { Message = "Are you sure you want to convert the Test Plan Reference to a Sequence?\n\nAny future changes to the referenced test plan will not be reflected."; if(multiSelect) Message += "\n\nThis will affect all selected test steps."; } } /// Convert the test plan reference to a sequence step. This will pop up a dialog. [Browsable(true)] [Display("Convert to Sequence", "Convert the test plan reference to a sequence step.", Order: 1.1)] [EnabledIf(nameof(anyStepsLoaded), HideIfDisabled = true)] public void ConvertToSequence() { // only show the dialog if the user input interface has been set. // otherwise treat this as a normal method. bool showDialog = UserInput.Interface != null; ConvertToSequence(showDialog, false); } internal bool ConvertToSequence(bool userShouldConfirm, bool multiSelect) { if (userShouldConfirm) { var warn = new ConvertWarning(multiSelect); UserInput.Request(warn, true); if (warn.Response == ConvertWarning.ConvertOrCancel.Cancel) return false; } // This test plan contains a clone of all the test steps in 'this'. var subPlan = TestPlan.Load(GetPath()); // the new sequence step. var seq = new SequenceStep { // Replace Test Plan Reference if the name starts with that. Name = Name.StartsWith("Test Plan Reference") ? ("Sequence" + Name.Substring("Test Plan Reference".Length)) : Name, Parent = Parent, Id = this.Id }; ChildItemVisibility.SetVisibility(seq, ChildItemVisibility.GetVisibility(this)); // first figure out which steps are parameterized to this in the list of child steps. var parameters = TypeData.GetTypeData(subPlan).GetMembers().OfType() .Select(e => new {Name = e.Name , Members = e.ParameterizedMembers.ToArray()}).ToArray(); foreach (var param in TypeData.GetTypeData(subPlan).GetMembers().OfType().ToArray()) { // unparameterize those. param.Remove(); } // now copy the steps over to the new step. var steps = subPlan.ChildTestSteps.ToArray(); subPlan.ChildTestSteps.Clear(); foreach (var step in steps) { seq.ChildTestSteps.Add(step); } ChildTestSteps.IsReadOnly = false; var parent = this.Parent; var idx = parent.ChildTestSteps.IndexOf(this); parent.ChildTestSteps[idx] = seq; // This section copies parameters over from the test plan reference to the sequence foreach (var parameter in parameters) { var name = parameter.Name; ParameterMemberData parameterMember = null; foreach (var member in parameter.Members) { parameterMember = member.Member.Parameterize(seq, member.Source, name); } parameterMember.SetValue(seq, ExternalParameters.First(x => x.Name == name).GetValue(this)); } // This section copies mixins over from the test plan reference to the sequence var seqType = TypeData.GetTypeData(seq); // Some MixinMemberData members may be seen multiple times // e.g. multiple EmbeddedMemberData can have the same OwnerMember // Store the seen members so that we can skip it HashSet seen = new HashSet(); foreach (var member in TypeData.GetTypeData(this).GetMembers()) { MixinMemberData mixinMember = member switch { MixinMemberData mixin => mixin, IEmbeddedMemberData emb => emb.OwnerMember as MixinMemberData, var _ => null }; if (mixinMember != null && !seen.Contains(mixinMember)) { seen.Add(mixinMember); var mixin = mixinMember.Source; var mem = mixin.ToDynamicMember(seqType); if (mem == null) { if (mixin is IValidatingObject validating && validating.Error is string err && string.IsNullOrEmpty(err) == false) { Log.Error($"Unable to load mixin: {err}"); } else { Log.Error($"Unable to load mixin: {TypeData.GetTypeData(mixin)?.GetDisplayAttribute()?.Name ?? mixin.ToString()}"); } continue; } DynamicMember.AddDynamicMember(seq, mem); mem.SetValue(seq, mixinMember.GetValue(this)); } } // This section migrates parameters to the new step. ParameterMemberData GetParameter(object target, object source, IMemberData parameterizedMember) { var parameterMembers = TypeData.GetTypeData(target).GetMembers().OfType(); foreach (var fwd in parameterMembers) { if (fwd.ContainsMember((source, parameterizedMember))) return fwd; } return null; } foreach (var member in TypeData.GetTypeData(this).GetMembers()) { if (member.IsParameterized(this)) { var parent2 = Parent; while (parent2 != null) { var p = GetParameter(parent2, this, member); if (p != null) { TypeData.GetTypeData(seq).GetMember(member.Name).Parameterize(parent2, seq, p.Name); member.Unparameterize(p, this); break; } parent2 = parent2.Parent; } } } // This section copies dynamic member values. foreach (var member in TypeData.GetTypeData(this).GetMembers().Where(mem => mem is DynamicMember)) { if (member.HasAttribute()) continue; var val = member.GetValue(this); val = new ObjectCloner(val).Clone(false, this, member.TypeDescriptor); member.SetValue(seq, val); } // .. and done. return true; } internal ParameterMemberData[] ExternalParameters { get; private set; } = Array.Empty(); } }