Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/js/tag.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,4 @@ tagElement.addEventListener(webexpress.webapp.Event.TAG_ADDED_EVENT, (e) => {

## Read-only Mode

Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlRestTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits.
Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlDataTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits.
4 changes: 2 additions & 2 deletions docs/js/theme-selector.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The `webexpress.webapp.DropdownTheme` is a REST-backed theme picker that extends

## Declarative Configuration

The control is rendered server-side by `ControlRestSelectionTheme`. Manual HTML usage is also supported:
The control is rendered server-side by `ControlDataSelectionTheme`. Manual HTML usage is also supported:

|Attribute |Description
|-------------------------|-----------------------------------------------------------------
Expand Down Expand Up @@ -74,7 +74,7 @@ public sealed class ThemeApi : RestApiTheme
}

// 2. drop the selector onto a page - it is a standalone dropdown:
new ControlRestSelectionTheme("themeSelector")
new ControlDataSelectionTheme("themeSelector")
{
RestUri = _ => sitemapManager.GetUri<ThemeApi>(applicationContext)
};
Expand Down
8 changes: 4 additions & 4 deletions src/WebExpress.WebApp.Test/Config/webexpress.config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

<endpoint uri="http://localhost/"/>

<limit>
<connectionlimit>300</connectionlimit>
<uploadlimit>3000000000</uploadlimit>
</limit>
<kestrel>
<maxconcurrentconnections>300</maxconcurrentconnections>
<maxrequestbodysize>3000000000</maxrequestbodysize>
</kestrel>

<culture>de-DE</culture>

Expand Down
71 changes: 71 additions & 0 deletions src/WebExpress.WebApp.Test/JsTest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Headless tests for the View, State and Service engine

This folder contains the headless unit tests for the phase zero engine of the
View, State and Service architecture. The engine modules live in
`src/WebExpress.WebUI/Assets/js` (store, service, renderer, intent and
component) and are described in
`WebExpress.WebApp/docs/architecture/view-state-service.md`.

The tests load the real, shipped engine modules through a Node `vm` context with
a minimal DOM stub, so they exercise the same files that the framework embeds,
not a copy.

## Requirements

Node 18 or newer. No npm packages are installed; the harness uses only the Node
standard library (`node:test`, `node:assert`, `node:vm`, `node:fs`). The Node
that ships with the Visual Studio "Node.js development" component works as well,
even though it is not added to the system PATH.

## Running

From this folder, when node is on the PATH:

```
node --test
```

Node discovers and runs every `*.test.mjs` file. The expected output ends with a
pass summary and an exit code of zero.

When node is not on the PATH, for example when it ships only with Visual Studio,
use the helper script, which locates node automatically:

```
./run.ps1
```

## Layout

| File | Purpose
|-----------------------|-----------------------------------------------------------------
| `harness.mjs` | Loads the engine modules (and optional application modules) into an isolated context with host stubs.
| `dom-stub.mjs` | A minimal DOM used by the renderer and the component tests.
| `engine.test.mjs` | Unit tests for the store, the service, the renderer, the intents and the component.
| `list.model.test.mjs` | Unit tests for the REST list model helpers (phase one), including an end to end path through a service.
| `table.model.test.mjs` | Unit tests for the REST table model helpers (phase two), including the query and the put update through a service.
| `restform.model.test.mjs` | Unit tests for the REST form model helpers (phase two): request shaping, response classification and error normalisation.
| `restwizard.model.test.mjs` | Unit tests for the REST wizard model helpers (phase two): step request shaping, cache decision and last step detection.
| `tab.model.test.mjs` | Unit tests for the REST tab model helpers (phase two): the list, create, reorder and close operations through a service.
| `comment.model.test.mjs` | Unit tests for the REST comment model helpers (phase two): endpoint url and path building and category normalisation.
| `kanban.model.test.mjs` | Unit tests for the REST kanban model helpers (phase two): board normalisation and the load and persist operations through a service.
| `watcher.model.test.mjs` | Unit tests for the watcher model helpers: list normalisation, user search url, candidate filtering, removal helpers and the load, add and remove operations through a service.
| `scrum.backlog.model.test.mjs` | Unit tests for the scrum backlog model helpers: board and sprint normalisation, sprint and item paths, rank bodies, the group filter and sort, the rank rewrite, the active sprint crossing and the persist operations through a service.
| `tile.model.test.mjs` | Unit tests for the REST tile model helpers: the page slice, the total reduction, the item to tile mapping and the load and persist operations through a service.
| `dashboard.model.test.mjs` | Unit tests for the REST dashboard model helpers: the column and widget normalisation and the load and persist operations through a service.
| `workflow.editor.model.test.mjs` | Unit tests for the workflow editor model helpers: the meta and catalog normalisation, the wire format read with its aliases and the wire payload build, plus the load and persist operations through a service.
| `comment.composer.model.test.mjs` | Unit tests for the comment composer model helpers: the categories url, the categories normalisation and the label parsing, plus the categories load and the comment post through a service.
| `input.unique.model.test.mjs` | Unit tests for the unique input model helpers: the header parsing, the request body shaping and the availability extraction with its field and status and code heuristics, plus a uniqueness check through the shared request.
| `selection.model.test.mjs` | Unit tests for the REST selection model helpers: the request url and init shaping and the response item mapping, plus a search through the shared request.
| `input.selection.model.test.mjs` | Unit tests for the REST input selection model helpers: the request url and init shaping and the item mapping with its data and aria tuples, plus a search through the shared request.
| `dropdown.theme.model.test.mjs` | Unit tests for the theme dropdown model helpers: the theme item mapping and the theme list normalisation, plus a themes load through the shared request.

## Relationship to the .NET test suite

The .NET test `WebExpress.WebUI.Test/WebInclude/UnitTestEngineAssets` verifies
that the engine modules are embedded as resources and registered in the correct
load order through the `IncludeJavaScript` Asset attributes. That test runs in
the normal xUnit suite and guards the build pipeline. The headless tests in this
folder guard the runtime behaviour of the engine and are intended to run wherever
Node is available, for example on a developer machine or in continuous
integration.
251 changes: 251 additions & 0 deletions src/WebExpress.WebApp.Test/JsTest/UnitTestJavaScript.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
using System.Diagnostics;

namespace WebExpress.WebApp.Test.JsTest
{
/// <summary>
/// Runs the headless JavaScript tests under JsTest as part of the regular
/// xUnit run. The JavaScript tests exercise the shipped client sources and
/// therefore need a JavaScript engine; they are executed through the
/// Node.js test runner. Node.js is an optional, external prerequisite, so
/// a missing or outdated installation skips the test with a warning
/// instead of failing the build. The lookup is platform independent: an
/// explicit WEBEXPRESS_NODE override, the PATH, and well-known install
/// locations (including the Node.js bundled with Visual Studio) are
/// probed in that order.
/// </summary>
public class UnitTestJavaScript
{
/// <summary>
/// The minimum Node.js major version required by the test harness.
/// </summary>
private const int MinimumNodeMajorVersion = 18;

/// <summary>
/// The maximum time the Node.js test runner may take before the run
/// is treated as failed.
/// </summary>
private static readonly TimeSpan _timeout = TimeSpan.FromMinutes(2);

private readonly ITestOutputHelper _output;

/// <summary>
/// Initializes a new instance of the class.
/// </summary>
/// <param name="output">The xUnit output sink for the runner log.</param>
public UnitTestJavaScript(ITestOutputHelper output)
{
_output = output;
}

/// <summary>
/// Executes every JsTest/*.test.mjs file through the Node.js test
/// runner and fails with the captured runner output when a JavaScript
/// test fails. Skips with a warning when Node.js is unavailable.
/// </summary>
[Fact]
public void RunJavaScriptTests()
{
var node = FindNodeExecutable();
if (node == null)
{
Assert.Skip("Warning: Node.js was not found (checked WEBEXPRESS_NODE, the PATH and " +
"well-known install locations). The JavaScript tests under JsTest were skipped.");
}

var major = GetNodeMajorVersion(node);
if (major < MinimumNodeMajorVersion)
{
Assert.Skip($"Warning: Node.js at '{node}' is unusable or too old " +
$"(major version {major}, required {MinimumNodeMajorVersion} or newer). " +
"The JavaScript tests under JsTest were skipped.");
}

var directory = GetJsTestDirectory();
var files = Directory.GetFiles(directory, "*.test.mjs").OrderBy(f => f).ToArray();
Assert.True(files.Length > 0, $"No *.test.mjs files were found in '{directory}'.");

var (exitCode, log) = RunNode(node, directory, ["--test", .. files]);

_output.WriteLine($"node: {node}");
_output.WriteLine(log);
Assert.True(exitCode == 0,
$"The JavaScript tests failed (node exit code {exitCode}).{Environment.NewLine}{log}");
}

/// <summary>
/// Locates the Node.js executable in a platform independent way. The
/// WEBEXPRESS_NODE environment variable wins so CI systems can pin a
/// specific runtime; otherwise the PATH and the conventional install
/// locations of the current platform are probed.
/// </summary>
/// <returns>The full path of the executable, or null when not found.</returns>
private static string FindNodeExecutable()
{
var overridePath = Environment.GetEnvironmentVariable("WEBEXPRESS_NODE");
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
{
return overridePath;
}

var fileName = OperatingSystem.IsWindows() ? "node.exe" : "node";
var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var entry in pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(entry.Trim(), fileName);
if (File.Exists(candidate))
{
return candidate;
}
}

return EnumerateWellKnownLocations().FirstOrDefault(File.Exists);
}

/// <summary>
/// Yields the conventional Node.js install locations of the current
/// platform. On Windows this includes the private Node.js that Visual
/// Studio ships for its build tooling, which is sufficient for the
/// test runner even when no standalone Node.js is installed.
/// </summary>
/// <returns>Candidate paths of the executable.</returns>
private static IEnumerable<string> EnumerateWellKnownLocations()
{
if (!OperatingSystem.IsWindows())
{
yield return "/usr/local/bin/node";
yield return "/usr/bin/node";
yield return "/opt/homebrew/bin/node";
yield break;
}

var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

yield return Path.Combine(programFiles, "nodejs", "node.exe");
yield return Path.Combine(programFilesX86, "nodejs", "node.exe");
yield return Path.Combine(localAppData, "Programs", "nodejs", "node.exe");

var visualStudioRoot = Path.Combine(programFiles, "Microsoft Visual Studio");
if (!Directory.Exists(visualStudioRoot))
{
yield break;
}
foreach (var versionDirectory in Directory.EnumerateDirectories(visualStudioRoot))
{
foreach (var editionDirectory in Directory.EnumerateDirectories(versionDirectory))
{
yield return Path.Combine(editionDirectory,
"MSBuild", "Microsoft", "VisualStudio", "NodeJs", "node.exe");
}
}
}

/// <summary>
/// Determines the major version of a Node.js executable by invoking
/// "node --version".
/// </summary>
/// <param name="node">The path of the executable.</param>
/// <returns>The major version, or -1 when the probe fails.</returns>
private static int GetNodeMajorVersion(string node)
{
try
{
var info = new ProcessStartInfo
{
FileName = node,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
info.ArgumentList.Add("--version");

using var process = Process.Start(info);
var version = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(10000);

// the output has the form "v24.12.0"
var major = version.TrimStart('v').Split('.')[0];
return int.TryParse(major, out var value) ? value : -1;
}
catch
{
return -1;
}
}

/// <summary>
/// Locates the JsTest source folder by walking up from the test
/// assembly location to the project directory. The tests must run
/// from the source tree because the harness resolves the shipped
/// JavaScript assets relative to its own location.
/// </summary>
/// <returns>The full path of the JsTest folder.</returns>
private static string GetJsTestDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var candidate = Path.Combine(directory.FullName, "JsTest");
if (Directory.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException(
$"The JsTest folder was not found above '{AppContext.BaseDirectory}'.");
}

/// <summary>
/// Runs the Node.js executable with the given arguments and captures
/// the combined output.
/// </summary>
/// <param name="node">The path of the executable.</param>
/// <param name="workingDirectory">The working directory of the run.</param>
/// <param name="arguments">The command line arguments.</param>
/// <returns>The exit code and the combined stdout/stderr log.</returns>
private static (int ExitCode, string Log) RunNode(string node, string workingDirectory, IEnumerable<string> arguments)
{
var info = new ProcessStartInfo
{
FileName = node,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var argument in arguments)
{
info.ArgumentList.Add(argument);
}

using var process = Process.Start(info);
// drain both streams concurrently so neither pipe can fill up and
// deadlock the runner
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();

if (!process.WaitForExit((int)_timeout.TotalMilliseconds))
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// the process ended between the timeout and the kill
}
return (-1, $"The Node.js test runner timed out after {_timeout.TotalSeconds:0} seconds.");
}
process.WaitForExit();

var log = string.Join(Environment.NewLine,
new[] { stdout.GetAwaiter().GetResult(), stderr.GetAwaiter().GetResult() }
.Where(part => !string.IsNullOrWhiteSpace(part)));
return (process.ExitCode, log);
}
}
}
Loading