diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8fa3e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +################# +## IDEA Rider +################# +.idea +.idea.* + +################# +## Visual Studio +################# + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +packages/ + +# Build results + +[Bb]uild/ +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# dotnet +*.lock.json +artifacts/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +coverage.xml + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml +*.pubxml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +#packages/ + +# Windows Azure Build Output +csx +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml +*.pfx +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + +############# +## Windows detritus +############# + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac crap +.DS_Store diff --git a/Hangfire.Console.sln b/Hangfire.Console.sln new file mode 100644 index 0000000..7c08937 --- /dev/null +++ b/Hangfire.Console.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25420.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{436BABB0-DBF7-4301-BC7F-CBB9D09FAD36}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{657DF223-42EC-4765-990B-334C62F602EA}" + ProjectSection(SolutionItems) = preProject + dashboard.png = dashboard.png + global.json = global.json + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBD2EEC6-826C-4477-B8F7-EADE2D944B74}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Hangfire.Console", "src\Hangfire.Console\Hangfire.Console.xproj", "{C18CBFCC-955B-4B21-B698-851CC56364AF}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Hangfire.Console.Tests", "tests\Hangfire.Console.Tests\Hangfire.Console.Tests.xproj", "{D5068E09-A43C-4B05-8068-C50E9497EB25}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.Build.0 = Release|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C18CBFCC-955B-4B21-B698-851CC56364AF} = {436BABB0-DBF7-4301-BC7F-CBB9D09FAD36} + {D5068E09-A43C-4B05-8068-C50E9497EB25} = {CBD2EEC6-826C-4477-B8F7-EADE2D944B74} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..29373ba --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Hangfire.Console + +Inspired by AppVeyor, Hangfire.Console provides a console-like logging experience for your jobs. + +![dashboard](dashboard.png) + +## Setup + +In .NET Core's Startup.cs: +```c# +public void ConfigureServices(IServiceCollection services) +{ + services.AddHangfire(config => + { + config.UseSqlServerStorage("connectionSting"); + config.UseConsole(); + }); +} +``` + +Otherwise, +```c# +GlobalConfiguration.Configuration + .UseSqlServerStorage("connectionSting") + .UseConsole(); +``` + +As usual, you may provide additional options for `UseConsole()` method. + +**NOTE**: If you have Dashboard and Server running separately, +you'll need to call `UseConsole()` on both. + +## Log + +Hangfire.Console provides extension methods on `PerformContext` object, +hence you'll need to add it as a job argument. + +Now you can write to console: + +```c# +public void TastMethod(PerformContext context) +{ + context.WriteLine("Hello, world!"); +} +``` + +Like with `System.Console`, you can specify text color for your messages: + +```c# +public void TastMethod(PerformContext context) +{ + context.SetTextColor(ConsoleTextColor.Red); + context.WriteLine("Error!"); + context.ResetTextColor(); +} +``` + +Unless specified otherwise, console sessions will expire in 24 hours. \ No newline at end of file diff --git a/dashboard.png b/dashboard.png new file mode 100644 index 0000000..e1d098e Binary files /dev/null and b/dashboard.png differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..eb92859 --- /dev/null +++ b/global.json @@ -0,0 +1,3 @@ +{ + "projects": [ "src", "tests" ] +} diff --git a/src/Hangfire.Console/ConsoleExtensions.cs b/src/Hangfire.Console/ConsoleExtensions.cs new file mode 100644 index 0000000..31ffb27 --- /dev/null +++ b/src/Hangfire.Console/ConsoleExtensions.cs @@ -0,0 +1,129 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Server; +using System; + +namespace Hangfire.Console +{ + /// + /// Provides extension methods for writing to console. + /// + public static class ConsoleExtensions + { + /// + /// Sets text color for next console lines. + /// + /// Context + /// Text color to use + public static void SetTextColor(this PerformContext context, ConsoleTextColor color) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (color == null) + throw new ArgumentNullException(nameof(color)); + + context.Items["ConsoleTextColor"] = color; + } + + /// + /// Resets text color for next console lines. + /// + /// Context + public static void ResetTextColor(this PerformContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + context.Items.Remove("ConsoleTextColor"); + } + + /// + /// Adds a string to console. + /// + /// Context + /// String + public static void WriteLine(this PerformContext context, string value) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (!context.Items.ContainsKey("ConsoleId")) + { + // Absence of ConsoleId means ConsoleServerFilter was not properly added + return; + } + + var consoleId = (ConsoleId)context.Items["ConsoleId"]; + + var line = new ConsoleLine(); + line.TimeOffset = Math.Round((DateTime.UtcNow - consoleId.Timestamp).TotalSeconds, 3); + line.Message = value ?? ""; + + if (context.Items.ContainsKey("ConsoleTextColor")) + { + line.TextColor = context.Items["ConsoleTextColor"].ToString(); + } + + using (var tran = context.Connection.CreateWriteTransaction()) + { + tran.AddToSet(consoleId.ToString(), JobHelper.ToJson(line)); + tran.Commit(); + } + } + + /// + /// Adds an empty line to console. + /// + /// Context + public static void WriteLine(this PerformContext context) + => WriteLine(context, ""); + + /// + /// Adds a value to a console. + /// + /// Context + /// Value + public static void WriteLine(this PerformContext context, object value) + => WriteLine(context, value?.ToString()); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0) + => WriteLine(context, string.Format(format, arg0)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0, object arg1) + => WriteLine(context, string.Format(format, arg0, arg1)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + /// Argument + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0, object arg1, object arg2) + => WriteLine(context, string.Format(format, arg0, arg1, arg2)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Arguments + public static void WriteLine(this PerformContext context, string format, params object[] args) + => WriteLine(context, string.Format(format, args)); + } +} diff --git a/src/Hangfire.Console/ConsoleOptions.cs b/src/Hangfire.Console/ConsoleOptions.cs new file mode 100644 index 0000000..adbbaef --- /dev/null +++ b/src/Hangfire.Console/ConsoleOptions.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hangfire.Console +{ + /// + /// Configuration options for console. + /// + public class ConsoleOptions + { + /// + /// Current options + /// + internal static ConsoleOptions Current { get; set; } + + /// + /// Gets or sets expiration time for console messages. + /// + public TimeSpan ExpireIn { get; set; } = TimeSpan.FromDays(1); + } +} diff --git a/src/Hangfire.Console/ConsoleTextColor.cs b/src/Hangfire.Console/ConsoleTextColor.cs new file mode 100644 index 0000000..e54d927 --- /dev/null +++ b/src/Hangfire.Console/ConsoleTextColor.cs @@ -0,0 +1,96 @@ +namespace Hangfire.Console +{ + /// + /// Text color values + /// + public class ConsoleTextColor + { + /// + /// The color dark blue. + /// + public static readonly ConsoleTextColor DarkBlue = new ConsoleTextColor("#000080"); + + /// + /// The color dark green. + /// + public static readonly ConsoleTextColor DarkGreen = new ConsoleTextColor("#008000"); + + /// + /// The color dark cyan (dark blue-green). + /// + public static readonly ConsoleTextColor DarkCyan = new ConsoleTextColor("#008080"); + + /// + /// The color dark red. + /// + public static readonly ConsoleTextColor DarkRed = new ConsoleTextColor("#800000"); + + /// + /// The color dark magenta (dark purplish-red). + /// + public static readonly ConsoleTextColor DarkMagenta = new ConsoleTextColor("#800080"); + + /// + /// The color dark yellow (ochre). + /// + public static readonly ConsoleTextColor DarkYellow = new ConsoleTextColor("#808000"); + + /// + /// The color gray. + /// + public static readonly ConsoleTextColor Gray = new ConsoleTextColor("#c0c0c0"); + + /// + /// The color dark gray. + /// + public static readonly ConsoleTextColor DarkGray = new ConsoleTextColor("#808080"); + + /// + /// The color blue. + /// + public static readonly ConsoleTextColor Blue = new ConsoleTextColor("#0000ff"); + + /// + /// The color green. + /// + public static readonly ConsoleTextColor Green = new ConsoleTextColor("#00ff00"); + + /// + /// The color cyan (blue-green). + /// + public static readonly ConsoleTextColor Cyan = new ConsoleTextColor("#00ffff"); + + /// + /// The color red. + /// + public static readonly ConsoleTextColor Red = new ConsoleTextColor("#ff0000"); + + /// + /// The color magenta (purplish-red). + /// + public static readonly ConsoleTextColor Magenta = new ConsoleTextColor("#ff00ff"); + + /// + /// The color yellow. + /// + public static readonly ConsoleTextColor Yellow = new ConsoleTextColor("#ffff00"); + + /// + /// The color white. + /// + public static readonly ConsoleTextColor White = new ConsoleTextColor("#ffffff"); + + private readonly string _color; + + private ConsoleTextColor(string color) + { + _color = color; + } + + /// + public override string ToString() + { + return _color; + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs b/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs new file mode 100644 index 0000000..f7ef8d7 --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs @@ -0,0 +1,49 @@ +using Hangfire.Dashboard; +using System.Threading.Tasks; +using Hangfire.Annotations; +using System.Text; +using Hangfire.Console.Serialization; +using System; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Provides incremental updates for a console. + /// + internal class ConsoleDispatcher : IDashboardDispatcher + { + private readonly ConsoleOptions _options; + + public ConsoleDispatcher(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public Task Dispatch([NotNull] DashboardContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var consoleId = ConsoleId.Parse(context.UriMatch.Groups[1].Value); + + var startArg = context.Request.GetQuery("start"); + + // try to parse offset at which we should start returning requests + int start; + if (string.IsNullOrEmpty(startArg) || !int.TryParse(startArg, out start)) + { + // if not provided or invalid, fetch records from the very start + start = 0; + } + + var buffer = new StringBuilder(); + ConsoleRenderer.RenderConsole(buffer, context.Storage, consoleId, start); + + context.Response.ContentType = "text/html"; + return context.Response.WriteAsync(buffer.ToString()); + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs b/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs new file mode 100644 index 0000000..ed10e54 --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs @@ -0,0 +1,144 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Dashboard; +using Hangfire.States; +using Hangfire.Storage; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Helper methods to render console shared between + /// and . + /// + internal static class ConsoleRenderer + { + private static readonly HtmlHelper Helper = new HtmlHelper(new DummyPage()); + + private class DummyPage : RazorPage + { + public override void Execute() + { + } + } + + /// + /// Renders a single to a buffer. + /// + /// Buffer + /// Line + /// Reference timestamp for time offset + public static void RenderLine(StringBuilder builder, ConsoleLine line, DateTime timestamp) + { + var offset = TimeSpan.FromSeconds(line.TimeOffset); + + builder.Append("
") + .Append(Helper.MomentTitle(timestamp + offset, Helper.ToHumanDuration(offset))) + .Append(Helper.HtmlEncode(line.Message)) + .Append("
"); + } + + /// + /// Renders a collection of to a buffer. + /// + /// Buffer + /// Lines + /// Reference timestamp for time offset + public static void RenderLines(StringBuilder builder, IEnumerable lines, DateTime timestamp) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (lines == null) return; + + foreach (var line in lines) + { + RenderLine(builder, line, timestamp); + } + } + + /// + /// Fetches and renders console contents to a buffer. + /// + /// Buffer + /// Job storage + /// Console identifier + /// Offset to read lines from + public static void RenderConsole(StringBuilder builder, JobStorage storage, ConsoleId consoleId, int start) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + if (storage == null) + throw new ArgumentNullException(nameof(storage)); + if (consoleId == null) + throw new ArgumentNullException(nameof(consoleId)); + + var items = ReadLines(storage, consoleId, ref start); + + builder.AppendFormat("
", consoleId, start); + RenderLines(builder, items, consoleId.Timestamp); + builder.Append("
"); + } + + /// + /// Fetches console lines from storage. + /// + /// Job storage + /// Console identifier + /// Offset to read lines from + /// + /// On completion, is set to the end of the current batch, + /// and can be used for next requests (or set to -1, if the job has finished processing). + /// + private static IEnumerable ReadLines(JobStorage storage, ConsoleId consoleId, ref int start) + { + if (start < 0) return null; + + using (var connection = (JobStorageConnection)storage.GetConnection()) + { + var count = (int)connection.GetSetCount(consoleId.ToString()); + var result = new List(Math.Max(1, count - start)); + + if (count > start) + { + // has some new items to fetch + + var items = connection.GetRangeFromSet(consoleId.ToString(), start, count); + foreach (var item in items) + { + var entry = JobHelper.FromJson(item); + result.Add(entry); + } + } + + if (count <= start || start == 0) + { + // no new items or initial load, check if the job is still performing + + var state = connection.GetStateData(consoleId.JobId); + if (state == null) + { + // No state found for a job, probably it was deleted + count = -2; + } + else if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase) || + JobHelper.DeserializeDateTime(state.Data["StartedAt"]) != consoleId.Timestamp) + { + // Job has changed its state + count = -1; + } + } + + start = count; + return result; + } + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs b/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs new file mode 100644 index 0000000..4a99aad --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs @@ -0,0 +1,80 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Dashboard; +using Hangfire.Dashboard.Extensions; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Replacement renderer for Processing state. + /// + internal class ProcessingStateRenderer + { + private readonly ConsoleOptions _options; + + public ProcessingStateRenderer(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public NonEscapedString Render(HtmlHelper helper, IDictionary stateData) + { + var builder = new StringBuilder(); + + builder.Append("
"); + + string serverId = null; + + if (stateData.ContainsKey("ServerId")) + { + serverId = stateData["ServerId"]; + } + else if (stateData.ContainsKey("ServerName")) + { + serverId = stateData["ServerName"]; + } + + if (serverId != null) + { + builder.Append("
Server:
"); + builder.Append($"
{helper.ServerId(serverId)}
"); + } + + if (stateData.ContainsKey("WorkerId")) + { + builder.Append("
Worker:
"); + builder.Append($"
{stateData["WorkerId"].Substring(0, 8)}
"); + } + else if (stateData.ContainsKey("WorkerNumber")) + { + builder.Append("
Worker:
"); + builder.Append($"
#{stateData["WorkerNumber"]}
"); + } + + builder.Append("
"); + + var page = helper.GetPage(); + if (page.RequestPath.StartsWith("/jobs/details/")) + { + // We cannot cast page to an internal type JobDetailsPage to get jobId :( + var jobId = page.RequestPath.Substring("/jobs/details/".Length); + + var startedAt = JobHelper.DeserializeDateTime(stateData["StartedAt"]); + + var consoleId = new ConsoleId(jobId, startedAt); + + builder.Append("
"); + ConsoleRenderer.RenderConsole(builder, page.Storage, consoleId, 0); + builder.Append("
"); + } + + return new NonEscapedString(builder.ToString()); + } + } +} diff --git a/src/Hangfire.Console/GlobalConfigurationExtensions.cs b/src/Hangfire.Console/GlobalConfigurationExtensions.cs new file mode 100644 index 0000000..2df67dc --- /dev/null +++ b/src/Hangfire.Console/GlobalConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using Hangfire.Console.Dashboard; +using Hangfire.Console.Server; +using Hangfire.Dashboard; +using Hangfire.Dashboard.Extensions; +using Hangfire.States; +using System; +using System.Reflection; + +namespace Hangfire.Console +{ + /// + /// Provides extension methods to setup Hangfire.Console. + /// + public static class GlobalConfigurationExtensions + { + /// + /// Configures Hangfire to use Console. + /// + /// Global configuration + /// Options for console + public static IGlobalConfiguration UseConsole(this IGlobalConfiguration configuration, ConsoleOptions options = null) + { + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); + + options = options ?? new ConsoleOptions(); + + // register server filter for jobs + GlobalJobFilters.Filters.Add(new ConsoleServerFilter(options)); + + // replace renderer for Processing state + JobHistoryRenderer.Register(ProcessingState.StateName, new ProcessingStateRenderer(options).Render); + + // register dispatcher to serve console updates + DashboardRoutes.Routes.Add("/console/([0-9a-f]{11}.+)", new ConsoleDispatcher(options)); + + // register additional dispatchers for CSS and JS + var assembly = typeof(ConsoleRenderer).GetTypeInfo().Assembly; + DashboardRoutes.Routes.Append("/js[0-9]{3}", new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.script.js")); + DashboardRoutes.Routes.Append("/css[0-9]{3}", new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.style.css")); + + return configuration; + } + } +} diff --git a/src/Hangfire.Console/Hangfire.Console.xproj b/src/Hangfire.Console/Hangfire.Console.xproj new file mode 100644 index 0000000..51422e4 --- /dev/null +++ b/src/Hangfire.Console/Hangfire.Console.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + c18cbfcc-955b-4b21-b698-851cc56364af + Hangfire.Console + .\obj + .\bin\ + v4.5.2 + + + + 2.0 + + + diff --git a/src/Hangfire.Console/Properties/AssemblyInfo.cs b/src/Hangfire.Console/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e79e184 --- /dev/null +++ b/src/Hangfire.Console/Properties/AssemblyInfo.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Console")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("c18cbfcc-955b-4b21-b698-851cc56364af")] + +[assembly: InternalsVisibleTo("Hangfire.Console.Tests")] \ No newline at end of file diff --git a/src/Hangfire.Console/Resources/script.js b/src/Hangfire.Console/Resources/script.js new file mode 100644 index 0000000..8ebfa0b --- /dev/null +++ b/src/Hangfire.Console/Resources/script.js @@ -0,0 +1,84 @@ +(function (hangfire) { + hangfire.Console = (function () { + function Console (el) { + this._el = el; + this._id = el.data('id'); + this._n = parseInt(el.data('n')) || 0; + } + + Console.prototype._load = function (start, replace) { + if (start < 0) return true; + + var url = hangfire.config && hangfire.config.pollUrl; + if (!url) return false; + + url = url.replace(/\/stats$/, "/console/" + this._id); + + var $this = this; + return $.get(url, { start: start }, function (data) { + var $data = $(data); + $this._n = parseInt($data.data('n')); + + // add lines + if (replace) $this._el.empty(); + $this._el.append($(".line", $data)); + + // set tooltips on new lines + $(".line span[data-moment-title]:not([title])", $this._el).each(function () { + var $this = $(this), + time = moment($this.data('moment-title'), 'X'); + $this.prop('title', time.format('llll')) + .attr('data-container', 'body'); + }).tooltip(); + }, "html"); + } + + Console.prototype.reload = function () { + this._load(0, true); + } + + Console.prototype.poll = function () { + if (this._timerId) return; + + if (this._n < 0) { + this._el.removeClass('active'); + return; + } + + var interval = 1000; + + var $this = this; + + this._el.addClass('active'); + this._timerId = setInterval(function () { + if (!$this._load($this._n, false) || $this._n < 0) { + $this._el.removeClass('active'); + clearInterval($this._timerId); + $this._timerId = null; + + if ($this._n === -1) { + // job has changed its state (but still exists) + location.reload(); + } + } + }, interval); + } + + return Console; + })(); + +})(window.Hangfire = window.Hangfire || {}); + +$(function () { + $(".console").each(function (index) { + var $this = $(this), + c = new Hangfire.Console($this); + + $this.data('console', c); + + if (index === 0) { + // poll on the first console + c.poll(); + } + }); +}); \ No newline at end of file diff --git a/src/Hangfire.Console/Resources/style.css b/src/Hangfire.Console/Resources/style.css new file mode 100644 index 0000000..bcf6f4e --- /dev/null +++ b/src/Hangfire.Console/Resources/style.css @@ -0,0 +1,66 @@ +.console-area { + padding: 0; + margin: 0 -10px; +} + +.console { + font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, monospace; + padding: 10px; + border: none; + margin: 10px 0 0 0; + + background-color: #0d3163; + color: #fff; +} + +.console:empty { + display: none; +} + +.console .line { + margin: 0; + line-height: 1.4em; + min-height: 1.4em; + font-size: 0.85em; + word-break: break-word; + white-space: pre-wrap; + vertical-align: top; +} + +.console .line > span[data-moment-title] { + display: none; /* timestamp is hidden in compact view */ +} + +@media only screen { + + .console.active .line:last-child:after { + display: inline-block; + content: url(data:image/gif;base64,R0lGODlhEAAFAPUAAA4yZP///w8zZBQ3aCJDcDlXf152l3SIpYydtaOxxKSyxPDy9fv8/f7+/g0xYw4yY4WXsOfq7w8yZBQ3Zxk7ayBBbytLdjRSfFVvkml/nmqAn2+Fon6RrJSkupWlu5qpvtne5uTn7fn6+xI1Zhg6ah0+bR4/bidHdDBOeUxnjE1njF51l2F4mWV8m3GGo3OIpHiMqISWr4uctMHK18vS3c3U387V3+Lm7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hZDcm9wcGVkIHdpdGggZXpnaWYuY29tACH5BAQUAP8ALAAAAAAQAAUAAAQZ0AHhqr2WIFWwdwcTJMB3GUuDmNdgHARrRQAh+QQFFAACACwAAAAACgAFAAAGKkCBQCIUAgCCCsdzEVo+H0smFOk8BJBAAIIBRTjCjUi0mWAyFSFFo6EEAQAh+QQFFAAOACwAAAAAEAAFAAAGPUDH4+EoGo8CgKPkgqCKJ5nsVCwoEIQVzRYrwiIRmAOQCDBeqVnNVWzdbq0iorEwjFQrU5HEYpGKBAcGA0EAOw==); + vertical-align: middle; + margin-left: 8px; + } + +} + +@media (min-width: 768px) { + + .console .line { + margin-left: 180px; /* same as dd */ + } + + .console .line > span[data-moment-title] { + display: inline-block; + width: 160px; /* same as dt */ + margin-left: -160px; + padding: 0 20px 0 10px; + + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + vertical-align: top; + white-space: pre; + + color: #00aad7; + } +} + diff --git a/src/Hangfire.Console/Serialization/ConsoleId.cs b/src/Hangfire.Console/Serialization/ConsoleId.cs new file mode 100644 index 0000000..d86c2de --- /dev/null +++ b/src/Hangfire.Console/Serialization/ConsoleId.cs @@ -0,0 +1,113 @@ +using System; + +namespace Hangfire.Console.Serialization +{ + /// + /// Console identifier + /// + internal class ConsoleId : IEquatable + { + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _cachedString = null; + + /// + /// Job identifier + /// + public string JobId { get; } + + /// + /// Processing timestamp + /// + public DateTime Timestamp { get; } + + /// + /// Initializes an instance of + /// + /// Job identifier + /// Processing timestamp + public ConsoleId(string jobId, DateTime timestamp) + { + if (string.IsNullOrEmpty(jobId)) + throw new ArgumentNullException(nameof(jobId)); + + JobId = jobId; + Timestamp = timestamp.ToUniversalTime(); + } + + /// + /// Creates an instance of from string representation. + /// + /// String + public static ConsoleId Parse(string value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + if (value.Length < 12) + throw new ArgumentException("Invalid value", nameof(value)); + + // Timestamp is serialized in reverse order for better randomness! + + long timestamp = 0; + for (int i = 10; i >= 0; i--) + { + var c = value[i] | 0x20; + + var x = (c >= '0' && c <= '9') ? (c - '0') : (c >= 'a' && c <= 'f') ? (c - 'a' + 10) : -1; + if (x == -1) + throw new ArgumentException("Invalid value", nameof(value)); + + timestamp = (timestamp << 4) | (long)x; + } + + return new ConsoleId(value.Substring(11), UnixEpoch.AddMilliseconds(timestamp)) { _cachedString = value }; + } + + /// + /// Determines if this instance is equal to instance. + /// + /// Other instance + public bool Equals(ConsoleId other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + + return other.Timestamp == Timestamp + && other.JobId == JobId; + } + + /// + public override string ToString() + { + if (_cachedString == null) + { + var buffer = new char[11 + JobId.Length]; + + var timestamp = (long)(Timestamp - UnixEpoch).TotalMilliseconds; + for (int i = 0; i < 11; i++, timestamp >>= 4) + { + var c = timestamp & 0x0F; + buffer[i] = (c < 10) ? (char)(c + '0') : (char)(c - 10 + 'a'); + } + + JobId.CopyTo(0, buffer, 11, JobId.Length); + + _cachedString = new string(buffer); + } + + return _cachedString; + } + + /// + public override bool Equals(object obj) + { + return Equals(obj as ConsoleId); + } + + /// + public override int GetHashCode() + { + return (JobId.GetHashCode() * 17) ^ Timestamp.GetHashCode(); + } + } +} diff --git a/src/Hangfire.Console/Serialization/ConsoleLine.cs b/src/Hangfire.Console/Serialization/ConsoleLine.cs new file mode 100644 index 0000000..bdba3ff --- /dev/null +++ b/src/Hangfire.Console/Serialization/ConsoleLine.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Hangfire.Console.Serialization +{ + internal class ConsoleLine + { + [JsonProperty("t", Required = Required.Always)] + public double TimeOffset { get; set; } + + [JsonProperty("s", Required = Required.Always)] + public string Message { get; set; } + + [JsonProperty("c", DefaultValueHandling = DefaultValueHandling.Ignore)] + public string TextColor { get; set; } + } +} diff --git a/src/Hangfire.Console/Server/ConsoleServerFilter.cs b/src/Hangfire.Console/Server/ConsoleServerFilter.cs new file mode 100644 index 0000000..9951c97 --- /dev/null +++ b/src/Hangfire.Console/Server/ConsoleServerFilter.cs @@ -0,0 +1,64 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Server; +using Hangfire.States; +using Hangfire.Storage; +using System; + +namespace Hangfire.Console.Server +{ + /// + /// Server filter to initialize and cleanup console environment. + /// + internal class ConsoleServerFilter : IServerFilter + { + private readonly ConsoleOptions _options; + + public ConsoleServerFilter(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public void OnPerforming(PerformingContext context) + { + var state = context.Connection.GetStateData(context.BackgroundJob.Id); + + if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase)) + { + // Not in Processing state? Something is really off... + return; + } + + var startedAt = JobHelper.DeserializeDateTime(state.Data["StartedAt"]); + + context.Items["ConsoleId"] = new ConsoleId(context.BackgroundJob.Id, startedAt); + } + + public void OnPerformed(PerformedContext context) + { + if (context.Canceled) + { + // Processing was been cancelled by one of the job filters + // There's nothing to do here, as processing hasn't started + return; + } + + if (!context.Items.ContainsKey("ConsoleId")) + { + // Something gone wrong in OnPerforming + return; + } + + var consoleId = (ConsoleId)context.Items["ConsoleId"]; + + using (var tran = (JobStorageTransaction)context.Connection.CreateWriteTransaction()) + { + tran.ExpireSet(consoleId.ToString(), _options.ExpireIn); + tran.Commit(); + } + } + } +} diff --git a/src/Hangfire.Console/Support/CompositeDispatcher.cs b/src/Hangfire.Console/Support/CompositeDispatcher.cs new file mode 100644 index 0000000..6dbfd0f --- /dev/null +++ b/src/Hangfire.Console/Support/CompositeDispatcher.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Dispatcher that combines output from several other dispatchers. + /// Used internally by . + /// + internal class CompositeDispatcher : IDashboardDispatcher + { + private readonly List _dispatchers; + + public CompositeDispatcher(params IDashboardDispatcher[] dispatchers) + { + _dispatchers = new List(dispatchers); + } + + public void AddDispatcher(IDashboardDispatcher dispatcher) + { + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + _dispatchers.Add(dispatcher); + } + + public async Task Dispatch(DashboardContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (_dispatchers.Count == 0) + throw new InvalidOperationException("CompositeDispatcher should contain at least one dispatcher"); + + foreach (var dispatcher in _dispatchers) + { + await dispatcher.Dispatch(context); + } + } + } +} diff --git a/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs b/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs new file mode 100644 index 0000000..65c0126 --- /dev/null +++ b/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs @@ -0,0 +1,60 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Alternative to built-in EmbeddedResourceDispatcher, which (for some reasons) is not public. + /// + internal class EmbeddedResourceDispatcher : IDashboardDispatcher + { + private readonly Assembly _assembly; + private readonly string _resourceName; + private readonly string _contentType; + + public EmbeddedResourceDispatcher(Assembly assembly, string resourceName, string contentType = null) + { + if (assembly == null) + throw new ArgumentNullException(nameof(assembly)); + if (string.IsNullOrEmpty(resourceName)) + throw new ArgumentNullException(nameof(resourceName)); + + _assembly = assembly; + _resourceName = resourceName; + _contentType = contentType; + } + + public Task Dispatch(DashboardContext context) + { + if (!string.IsNullOrEmpty(_contentType)) + { + var contentType = context.Response.ContentType; + + if (string.IsNullOrEmpty(contentType)) + { + // content type not yet set + context.Response.ContentType = _contentType; + } + else if (contentType != _contentType) + { + // content type already set, but doesn't match ours + throw new InvalidOperationException($"ContentType '{_contentType}' conflicts with '{context.Response.ContentType}'"); + } + } + + return WriteResourceAsync(context.Response, _assembly, _resourceName); + } + + private static async Task WriteResourceAsync(DashboardResponse response, Assembly assembly, string resourceName) + { + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + throw new ArgumentException($@"Resource '{resourceName}' not found in assembly {assembly}."); + + await stream.CopyToAsync(response.Body); + } + } + } +} diff --git a/src/Hangfire.Console/Support/HtmlHelperExtensions.cs b/src/Hangfire.Console/Support/HtmlHelperExtensions.cs new file mode 100644 index 0000000..36d93be --- /dev/null +++ b/src/Hangfire.Console/Support/HtmlHelperExtensions.cs @@ -0,0 +1,25 @@ +using System; +using System.Reflection; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Provides extension methods for . + /// + internal static class HtmlHelperExtensions + { + private static readonly FieldInfo _page = typeof(HtmlHelper).GetTypeInfo().GetDeclaredField(nameof(_page)); + + /// + /// Returs a associated with . + /// + /// Helper + public static RazorPage GetPage(this HtmlHelper helper) + { + if (helper == null) + throw new ArgumentNullException(nameof(helper)); + + return (RazorPage)_page.GetValue(helper); + } + } +} diff --git a/src/Hangfire.Console/Support/RouteCollectionExtensions.cs b/src/Hangfire.Console/Support/RouteCollectionExtensions.cs new file mode 100644 index 0000000..2dbfc53 --- /dev/null +++ b/src/Hangfire.Console/Support/RouteCollectionExtensions.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Provides extension methods for . + /// + internal static class RouteCollectionExtensions + { + private static readonly FieldInfo _dispatchers = typeof(RouteCollection).GetTypeInfo().GetDeclaredField(nameof(_dispatchers)); + + /// + /// Returns a private list of registered routes. + /// + /// Route collection + private static List> GetDispatchers(this RouteCollection routes) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + + return (List>)_dispatchers.GetValue(routes); + } + + /// + /// Combines exising dispatcher for with . + /// If there's no dispatcher for the specified path, adds a new one. + /// + /// Route collection + /// Path template + /// Dispatcher to add or append for specified path + public static void Append(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + var composite = pair.Item2 as CompositeDispatcher; + if (composite == null) + { + // replace original dispatcher with a composite one + composite = new CompositeDispatcher(pair.Item2); + list[i] = new Tuple(pathTemplate, composite); + } + + composite.AddDispatcher(dispatcher); + return; + } + } + + routes.Add(pathTemplate, dispatcher); + } + + /// + /// Replaces exising dispatcher for with . + /// If there's no dispatcher for the specified path, adds a new one. + /// + /// Route collection + /// Path template + /// Dispatcher to set for specified path + public static void Replace(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + list[i] = new Tuple(pathTemplate, dispatcher); + return; + } + } + + routes.Add(pathTemplate, dispatcher); + } + + /// + /// Removes dispatcher for . + /// + /// Route collection + /// Path template + public static void Remove(this RouteCollection routes, string pathTemplate) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + list.RemoveAt(i); + return; + } + } + } + } +} diff --git a/src/Hangfire.Console/project.json b/src/Hangfire.Console/project.json new file mode 100644 index 0000000..9653c25 --- /dev/null +++ b/src/Hangfire.Console/project.json @@ -0,0 +1,33 @@ +{ + "version": "1.0.0-*", + "title": "Hangfire.Console", + "description": "Job console for Hangfire", + "authors": [ "Alexey Skalozub" ], + + "packOptions": { + "summary": "Job console extension for Hangfire", + "tags": [ "hangfire", "console", "logging" ], + "owners": [ "Alexey Skalozub" ], + "releaseNotes": "Initial release", + "repository": { + "type": "git", + "url": "https://github.com/pieceofsummer/Hangfire.Console" + } + }, + + "buildOptions": { + "warningsAsErrors": true, + "xmlDoc": true, + + "embed": [ "Resources/*" ] + }, + + "dependencies": { + "Hangfire.Core": "1.6.6" + }, + + "frameworks": { + "netstandard1.3": {}, + "net45": {} + } +} diff --git a/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs b/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs new file mode 100644 index 0000000..fd043f3 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs @@ -0,0 +1,55 @@ +using Hangfire.Console.Dashboard; +using Hangfire.Console.Serialization; +using System; +using System.Text; +using Xunit; + +namespace Hangfire.Console.Tests.Dashboard +{ + public class ConsoleRendererFacts + { + [Fact] + public void RendersLine() + { + var line = new ConsoleLine() { TimeOffset = 0, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+ <1mstest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithOffset() + { + var line = new ConsoleLine() { TimeOffset = 1.108, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+1.108stest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithNegativeOffset() + { + var line = new ConsoleLine() { TimeOffset = -1.206, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
-1.206stest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithColor() + { + var line = new ConsoleLine() { TimeOffset = 0, Message = "test", TextColor = "#ffffff" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+ <1mstest
", builder.ToString()); + } + } +} diff --git a/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj b/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj new file mode 100644 index 0000000..8761a88 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj @@ -0,0 +1,22 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + d5068e09-a43c-4b05-8068-c50e9497eb25 + Hangfire.Console.Tests + .\obj + .\bin\ + v4.5.2 + + + 2.0 + + + + + + \ No newline at end of file diff --git a/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs b/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d54f0d5 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Console.Tests")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d5068e09-a43c-4b05-8068-c50e9497eb25")] diff --git a/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs b/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs new file mode 100644 index 0000000..58ed67f --- /dev/null +++ b/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs @@ -0,0 +1,55 @@ +using Hangfire.Console.Serialization; +using System; +using Xunit; + +namespace Hangfire.Console.Tests.Serialization +{ + public class ConsoleIdFacts + { + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsNull() + { + Assert.Throws("jobId", () => new ConsoleId(null, DateTime.UtcNow)); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsEmpty() + { + Assert.Throws("jobId", () => new ConsoleId("", DateTime.UtcNow)); + } + + [Fact] + public void SerializesCorrectly() + { + var x = new ConsoleId("123", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var s = x.ToString(); + Assert.Equal("00cdb7af151123", s); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsNull() + { + Assert.Throws("value", () => ConsoleId.Parse(null)); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsTooShort() + { + Assert.Throws("value", () => ConsoleId.Parse("00cdb7af1")); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsInvalid() + { + Assert.Throws("value", () => ConsoleId.Parse("00x00y00z001")); + } + + [Fact] + public void DeserializesCorrectly() + { + var x = ConsoleId.Parse("00cdb7af151123"); + Assert.Equal("123", x.JobId); + Assert.Equal(new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc), x.Timestamp); + } + } +} diff --git a/tests/Hangfire.Console.Tests/project.json b/tests/Hangfire.Console.Tests/project.json new file mode 100644 index 0000000..c6754b9 --- /dev/null +++ b/tests/Hangfire.Console.Tests/project.json @@ -0,0 +1,29 @@ +{ + "version": "1.0.0-*", + + "buildOptions": { + "debugType": "portable" + }, + + "testRunner": "xunit", + + "dependencies": { + "xunit": "2.2.0-*", + "dotnet-test-xunit": "2.2.0-*", + "Moq": "4.6.38-alpha", + "Hangfire.Console": { "target": "project" } + }, + + "frameworks": { + "netcoreapp1.0": { + "imports": "dnxcore50", + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.1" + }, + "System.Reflection.TypeExtensions": "4.1.0" + } + } + } +}