From 1e2daffea0f73e6630192d6ea8b7d8547c6507dc Mon Sep 17 00:00:00 2001 From: Theauxm <43664045+Theauxm@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:00 -0600 Subject: [PATCH 1/5] test(machines): fail the conformance tests when the shared fixtures are missing The fixture locator returned null when Trax.Api.StateMachine/machines was not found, and every cross-engine test then called Assert.Ignore, a leftover from when the TypeScript engine was a separate repository. In this repository the folder is always there, so a copy or a CI path filter that left it out reported green without comparing the engines. The locator now throws, naming every directory it searched. --- .../Conformance/CheckoutConformanceTests.cs | 9 +-- .../Conformance/ConformanceTests.cs | 9 --- .../Conformance/FixturePathsTests.cs | 39 +++++++++++++ .../Conformance/IrFormatParityTests.cs | 5 -- .../Conformance/MigrationGoldenTests.cs | 8 --- .../Conformance/RefusalCodeParityTests.cs | 5 -- .../Differential/DeclarativeTurnstileTests.cs | 6 -- .../DifferentialConformanceTests.cs | 9 --- .../DifferentialCorpusReplayTests.cs | 3 +- .../Differential/TwinSequenceParityTests.cs | 5 -- .../Helpers/FixturePaths.cs | 58 +++++++++++-------- .../Helpers/TwinEngine.cs | 7 +-- .../UnitTests/InvokesDeclarationTests.cs | 5 +- .../UnitTests/RulesVocabularyTests.cs | 3 +- .../UnitTests/TimerDeclarationTests.cs | 5 +- 15 files changed, 81 insertions(+), 95 deletions(-) create mode 100644 Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/FixturePathsTests.cs diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/CheckoutConformanceTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/CheckoutConformanceTests.cs index 6fb3a719c..494db9a6f 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/CheckoutConformanceTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/CheckoutConformanceTests.cs @@ -17,8 +17,6 @@ public class CheckoutConformanceTests public static IEnumerable AdvanceFixtures() { var dir = FixturePaths.AdvanceDir("checkout"); - if (dir is null || !Directory.Exists(dir)) - yield break; foreach ( var path in Directory .EnumerateFiles(dir, "*.json") @@ -32,8 +30,6 @@ var path in Directory public static IEnumerable RehydrateFixtures() { var dir = FixturePaths.RehydrateDir("checkout"); - if (dir is null || !Directory.Exists(dir)) - yield break; foreach ( var path in Directory .EnumerateFiles(dir, "*.json") @@ -109,13 +105,10 @@ public void Rehydrate_matches_the_shared_fixture(string path) [Test] public void Serialize_canonicalizes_the_multi_key_wire_handoff_samples() { - if (FixturePaths.MachinesRoot is null) - Assert.Ignore("Shared checkout fixtures not found (isolated build)."); - var handoff = (JsonObject) JsonNode.Parse( File.ReadAllText( - Path.Combine(FixturePaths.MachinesRoot!, "checkout", "wire-handoff.json") + Path.Combine(FixturePaths.MachinesRoot, "checkout", "wire-handoff.json") ) )!; diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceTests.cs index 188e736ef..dbfc3073b 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceTests.cs @@ -18,8 +18,6 @@ public class ConformanceTests public static IEnumerable AdvanceFixtures() { var dir = FixturePaths.AdvanceDir("turnstile"); - if (dir is null || !Directory.Exists(dir)) - yield break; foreach ( var path in Directory .EnumerateFiles(dir, "*.json") @@ -33,8 +31,6 @@ var path in Directory public static IEnumerable RehydrateFixtures() { var dir = FixturePaths.RehydrateDir("turnstile"); - if (dir is null || !Directory.Exists(dir)) - yield break; foreach ( var path in Directory .EnumerateFiles(dir, "*.json") @@ -48,11 +44,6 @@ var path in Directory [Test] public void Shared_fixtures_are_reachable() { - if (FixturePaths.MachinesRoot is null) - Assert.Ignore( - "Shared Trax.Api.StateMachine/machines fixtures not found (isolated build). In CI they are supplied by the Trax.StateMachine.Fixtures package." - ); - Directory.Exists(FixturePaths.AdvanceDir("turnstile")).Should().BeTrue(); } diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/FixturePathsTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/FixturePathsTests.cs new file mode 100644 index 000000000..84ce13939 --- /dev/null +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/FixturePathsTests.cs @@ -0,0 +1,39 @@ +using AwesomeAssertions; +using Trax.Effect.StateMachine.Tests.Helpers; + +namespace Trax.Effect.StateMachine.Tests.Conformance; + +/// +/// The conformance tests read Trax.Api.StateMachine/machines, which is part of the repository. A build that +/// cannot find it must fail them rather than skip them, or every cross-engine check reports green unchecked. +/// +public class FixturePathsTests +{ + [Test] + public void A_directory_with_no_machines_folder_above_it_throws_naming_where_it_looked() + { + var start = Path.Combine(Path.GetTempPath(), $"trax-no-fixtures-{Guid.NewGuid():N}", "bin"); + Directory.CreateDirectory(start); + try + { + var find = () => FixturePaths.Find(start); + + find.Should() + .Throw() + .WithMessage($"*{Path.Combine(start, "Trax.Api.StateMachine", "machines")}*"); + } + finally + { + Directory.Delete(Path.GetDirectoryName(start)!, recursive: true); + } + } + + [Test] + public void The_test_output_finds_the_shared_machines_folder() + { + Directory + .Exists(Path.Combine(FixturePaths.MachinesRoot, "turnstile", "fixtures")) + .Should() + .BeTrue(); + } +} diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrFormatParityTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrFormatParityTests.cs index 9a698b0d2..758457f8a 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrFormatParityTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrFormatParityTests.cs @@ -15,11 +15,6 @@ public partial class IrFormatParityTests [Test] public void The_TypeScript_engine_reads_the_IR_format_this_engine_exports() { - if (FixturePaths.MachinesRoot is null) - Assert.Ignore( - "Trax.Api.StateMachine not found (isolated build), so there is no TypeScript engine to compare." - ); - var irFormatTs = Path.Combine( Directory.GetParent(FixturePaths.MachinesRoot)!.FullName, "src", diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/MigrationGoldenTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/MigrationGoldenTests.cs index 9f75ffbee..8e9a6c701 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/MigrationGoldenTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/MigrationGoldenTests.cs @@ -17,9 +17,6 @@ public class MigrationGoldenTests public static IEnumerable MigrationCases() { var file = FixturePaths.MigrationFile("turnstile"); - if (file is null || !File.Exists(file)) - yield break; - var golden = (JsonObject)JsonNode.Parse(File.ReadAllText(file))!; foreach (var node in (JsonArray)golden["cases"]!) { @@ -35,11 +32,6 @@ public static IEnumerable MigrationCases() [Test] public void Shared_migration_golden_is_reachable() { - if (FixturePaths.MachinesRoot is null) - Assert.Ignore( - "Shared Trax.Api.StateMachine/machines fixtures not found (isolated build). In CI they are supplied by the Trax.StateMachine.Fixtures package." - ); - File.Exists(FixturePaths.MigrationFile("turnstile")).Should().BeTrue(); } diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs index bc2ed2aa1..b78e0505b 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs @@ -74,11 +74,6 @@ public void A_code_an_older_node_recorded_in_kebab_case_still_reads() [Test] public void The_TypeScript_package_exports_the_same_codes() { - if (FixturePaths.MachinesRoot is null) - Assert.Ignore( - "Trax.Api.StateMachine not found (isolated build), so there is no TypeScript package to compare." - ); - var resultsTs = Path.Combine( Directory.GetParent(FixturePaths.MachinesRoot)!.FullName, "src", diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DeclarativeTurnstileTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DeclarativeTurnstileTests.cs index 6643bf80b..957251f5c 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DeclarativeTurnstileTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DeclarativeTurnstileTests.cs @@ -17,12 +17,6 @@ public class DeclarativeTurnstileTests public void Declarative_turnstile_reproduces_the_shared_differential_corpus() { var file = FixturePaths.DifferentialFile("turnstile"); - if (file is null || !File.Exists(file)) - { - Assert.Ignore("Shared turnstile differential corpus not found (isolated build)."); - return; - } - var diffs = DifferentialCorpus.Replay(DeclarativeTurnstile.Machine, File.ReadAllText(file)); diffs diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialConformanceTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialConformanceTests.cs index 7c605fa4e..fd4ae0ea2 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialConformanceTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialConformanceTests.cs @@ -35,15 +35,6 @@ SnapshotMachine machine where TTrigger : struct, Enum { var file = FixturePaths.DifferentialFile(name); - if (file is null || !File.Exists(file)) - { - Assert.Ignore( - $"Shared {name} differential corpus not found (isolated build). In CI it is supplied " - + "by the Trax.StateMachine.Fixtures package." - ); - return; - } - var diffs = DifferentialCorpus.Replay(machine, File.ReadAllText(file)); diffs .Should() diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialCorpusReplayTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialCorpusReplayTests.cs index 8c9c2b5be..0c0f15b9c 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialCorpusReplayTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/DifferentialCorpusReplayTests.cs @@ -97,8 +97,7 @@ public void Outcome_triggers_apply_the_same_reduction_in_the_twin() // context, byte for byte. This is the pure half only: the twin holds no token, so a stale completion is // indistinguishable from a live one here. var file = FixturePaths.DifferentialFile("ingest"); - file.Should().NotBeNull("the shared machines/ directory is part of the repository"); - var corpus = File.ReadAllText(file!); + var corpus = File.ReadAllText(file); var outcomeCases = JsonNode.Parse(corpus)!["cases"]! .AsArray() diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/TwinSequenceParityTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/TwinSequenceParityTests.cs index 59387d746..758848dc2 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/TwinSequenceParityTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Differential/TwinSequenceParityTests.cs @@ -241,11 +241,6 @@ public void Random_event_sequences_with_timers_fired_at_random_points_agree() if (!NodeIsAvailable()) Unavailable("node is not on PATH."); var machinesRoot = FixturePaths.MachinesRoot; - if (machinesRoot is null) - { - Unavailable("Trax.Api.StateMachine is not checked out beside this folder."); - return; - } var engineRoot = Directory.GetParent(machinesRoot)!.FullName; // An empty request first: it skips the test when the engine's dependencies are absent, which cannot be diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs index 37a285879..fff8ee542 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs @@ -1,48 +1,58 @@ namespace Trax.Effect.StateMachine.Tests.Helpers; /// -/// Locates the shared, language-neutral fixtures that live in the sibling Trax.Api.StateMachine -/// repo (the source of truth both engines drive). In a full workspace checkout it walks up to the -/// workspace root and into Trax.Api.StateMachine/machines; in an isolated build the fixtures are -/// absent and the conformance tests skip (in CI they are supplied by the fixtures package instead). +/// Locates the shared, language-neutral fixtures in Trax.Api.StateMachine/machines, which both engines drive. +/// The folder is part of the repository, so a build that cannot find it is broken: every member throws rather than +/// letting the conformance tests skip, which would report green without comparing the engines. /// public static class FixturePaths { - private static readonly Lazy Root = new(Find); + private static readonly Lazy Root = new(() => Find(AppContext.BaseDirectory)); - /// The shared machines/ directory, or null if this is an isolated build. - public static string? MachinesRoot => Root.Value; + /// The shared machines/ directory. + public static string MachinesRoot => Root.Value; - public static string? AdvanceDir(string machine) => - MachinesRoot is null ? null : Path.Combine(MachinesRoot, machine, "fixtures", "advance"); + public static string AdvanceDir(string machine) => + Path.Combine(MachinesRoot, machine, "fixtures", "advance"); - public static string? RehydrateDir(string machine) => - MachinesRoot is null ? null : Path.Combine(MachinesRoot, machine, "fixtures", "rehydrate"); + public static string RehydrateDir(string machine) => + Path.Combine(MachinesRoot, machine, "fixtures", "rehydrate"); - /// The shared, TypeScript-generated differential corpus for a machine (or null if isolated). - public static string? DifferentialFile(string machine) => - MachinesRoot is null ? null : Path.Combine(MachinesRoot, machine, "differential.json"); + /// The shared, TypeScript-generated differential corpus for a machine. + public static string DifferentialFile(string machine) => + Path.Combine(MachinesRoot, machine, "differential.json"); - /// The committed IR of a machine, which the TypeScript twin and its corpus are generated from (or null if isolated). - public static string? IrFile(string machine) => - MachinesRoot is null ? null : Path.Combine(MachinesRoot, machine, $"{machine}.ir.json"); + /// The committed IR of a machine, which the TypeScript twin and its corpus are generated from. + public static string IrFile(string machine) => + Path.Combine(MachinesRoot, machine, $"{machine}.ir.json"); /// The shared migration golden for a machine: stored older-version snapshots and the exact - /// canonical wire each must become after forward migration (or null if isolated). - public static string? MigrationFile(string machine) => - MachinesRoot is null ? null : Path.Combine(MachinesRoot, machine, "migration.json"); - - private static string? Find() + /// canonical wire each must become after forward migration. + public static string MigrationFile(string machine) => + Path.Combine(MachinesRoot, machine, "migration.json"); + + /// + /// Walks up from to the first directory holding + /// Trax.Api.StateMachine/machines/turnstile/fixtures, and throws, naming every directory it tried, when + /// there is none. + /// + public static string Find(string start) { - var dir = new DirectoryInfo(AppContext.BaseDirectory); + var searched = new List(); + var dir = new DirectoryInfo(start); while (dir is not null) { var candidate = Path.Combine(dir.FullName, "Trax.Api.StateMachine", "machines"); if (Directory.Exists(Path.Combine(candidate, "turnstile", "fixtures"))) return candidate; + searched.Add(candidate); dir = dir.Parent; } - return null; + throw new DirectoryNotFoundException( + "The shared state-machine fixtures (Trax.Api.StateMachine/machines) were not found. They are part of the " + + "repository, so the build or the copy that ran these tests left them out. Searched: " + + string.Join(", ", searched) + ); } } diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs index ca3757b98..5f7aae105 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs @@ -96,12 +96,7 @@ private static string EngineRoot() { if (!NodeIsAvailable()) Unavailable("node is not on PATH."); - if (FixturePaths.MachinesRoot is not { } machinesRoot) - { - Unavailable("Trax.Api.StateMachine is not checked out beside this folder."); - throw new UnreachableException(); - } - return Directory.GetParent(machinesRoot)!.FullName; + return Directory.GetParent(FixturePaths.MachinesRoot)!.FullName; } // Skips the test on a developer's machine; fails it under CI, where the engine is always installed. diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/InvokesDeclarationTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/InvokesDeclarationTests.cs index 38775fea4..ab14fde74 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/InvokesDeclarationTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/InvokesDeclarationTests.cs @@ -597,13 +597,12 @@ public void A_machine_that_invokes_nothing_exports_no_outcomes() public void The_ingest_IR_matches_the_committed_golden_the_twin_reads() { var file = FixturePaths.IrFile("ingest"); - file.Should().NotBeNull("the shared machines/ directory is part of the repository"); var exported = IrExporter.Export(IngestMachine.Built); if (Environment.GetEnvironmentVariable("UPDATE_IR") == "1") - File.WriteAllText(file!, exported + "\n"); + File.WriteAllText(file, exported + "\n"); - File.ReadAllText(file!) + File.ReadAllText(file) .TrimEnd('\n') .Should() .Be(exported, "regenerate the golden deliberately with UPDATE_IR=1"); diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesVocabularyTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesVocabularyTests.cs index 2f4ff8e4a..36b6901c6 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesVocabularyTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesVocabularyTests.cs @@ -65,9 +65,8 @@ public void The_gauge_exports_lt_gte_and_lte() public void The_gauge_IR_matches_the_committed_golden_the_twin_reads() { var file = FixturePaths.IrFile("gauge"); - file.Should().NotBeNull("the shared machines/ directory is part of the repository"); - File.ReadAllText(file!) + File.ReadAllText(file) .TrimEnd('\n') .Should() .Be( diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/TimerDeclarationTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/TimerDeclarationTests.cs index 732edd9d8..4bdddb687 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/TimerDeclarationTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/TimerDeclarationTests.cs @@ -431,13 +431,12 @@ public void A_timer_edge_carries_the_union_of_the_rules_entering_its_source() public void The_reminder_IR_matches_the_committed_golden_the_twin_reads() { var file = Helpers.FixturePaths.IrFile("reminder"); - file.Should().NotBeNull("the shared machines/ directory is part of the repository"); var exported = IrExporter.Export(ReminderMachine.Built); if (Environment.GetEnvironmentVariable("UPDATE_IR") == "1") - File.WriteAllText(file!, exported + "\n"); + File.WriteAllText(file, exported + "\n"); - File.ReadAllText(file!) + File.ReadAllText(file) .TrimEnd('\n') .Should() .Be( From d1280498a0482d11125f4623e0bb25edc56ec3dd Mon Sep 17 00:00:00 2001 From: Theauxm <43664045+Theauxm@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:54:21 -0600 Subject: [PATCH 2/5] test(machines): share the vectors both engines check as data The canonical-wire vectors were copied into a C# and a TypeScript test and had drifted: only C# checked lone surrogates and the \b \f \r escapes. The format verdicts lived in a TS-only table beside the C# strict-input cases. Both are now files under Trax.Api.StateMachine/conformance/ that each engine reads: - canonical-wire.json, the union of both tables. - strict-input/, the strict machine's IR and every StrictInputMachine case with the C# engine's answer, written by StrictInputGoldenTests (UPDATE_CONFORMANCE=1) and replayed by vitest without .NET. The seven TS-only format rows moved into the C# cases, where the CLR decides their verdict. - refusal-codes.json, the closed set both engines check themselves against, in place of C# parsing results.ts with a regex. --- Trax.Api.StateMachine/AGENTS.md | 5 +- .../conformance/canonical-wire.json | 37 +++++ .../conformance/refusal-codes.json | 45 ++++++ .../conformance/strict-input/cases.json | 144 +++++++++++++++++ .../conformance/strict-input/strict.ir.json | 1 + .../src/canonicalization.conformance.test.ts | 64 +++----- Trax.Api.StateMachine/src/fixtures.ts | 3 + .../src/refusalCodes.conformance.test.ts | 18 +++ .../src/rules/formats.test.ts | 64 +------- .../src/strictInput.conformance.test.ts | 45 ++++++ .../statemachine-api/result-codes.md | 3 +- .../Conformance/RefusalCodeParityTests.cs | 43 ++---- .../Conformance/StrictInputGoldenTests.cs | 145 ++++++++++++++++++ .../Fakes/StrictInputMachine.cs | 10 ++ .../Helpers/FixturePaths.cs | 7 + .../CanonicalizationConformanceTests.cs | 133 ++++------------ 16 files changed, 526 insertions(+), 241 deletions(-) create mode 100644 Trax.Api.StateMachine/conformance/canonical-wire.json create mode 100644 Trax.Api.StateMachine/conformance/refusal-codes.json create mode 100644 Trax.Api.StateMachine/conformance/strict-input/cases.json create mode 100644 Trax.Api.StateMachine/conformance/strict-input/strict.ir.json create mode 100644 Trax.Api.StateMachine/src/refusalCodes.conformance.test.ts create mode 100644 Trax.Api.StateMachine/src/strictInput.conformance.test.ts create mode 100644 Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs diff --git a/Trax.Api.StateMachine/AGENTS.md b/Trax.Api.StateMachine/AGENTS.md index 35500b131..af2a57030 100644 --- a/Trax.Api.StateMachine/AGENTS.md +++ b/Trax.Api.StateMachine/AGENTS.md @@ -16,6 +16,7 @@ This file is the entry point. It routes; it does not restate the rules. | `src/rules/generateTypes.ts` | `generateContextTypes` and `generateMachineFactory`, which turn an IR into a twin. | | `tools/` | The node entrypoints the `trax machine` commands shell out to (`generate-twin.mjs`, `generate-corpus.mjs`), and `bundle-cli.mjs`, which bundles them with the engine compiled in for Trax.Cli's package. | | `machines/` | The shared fixtures both engines drive: each machine's IR, its generated twin and its differential corpus. | +| `conformance/` | Engine-neutral tables any engine replays: the canonical-wire vectors, the refusal-code registry, the strict-input cases (written from the CLR by the C# `StrictInputGoldenTests`). A vector goes here, not in one engine's tests. | | `docs/design/` | The design notes the codegen was built from. Historical: the published docs are the rule. | ## The rules, and where they are written @@ -45,5 +46,5 @@ npm test # vitest: conformance, totality, serialization, migration, cod npm run typecheck ``` -The Trax.Effect conformance tests and the Trax.Cli byte-parity tests read `machines/`, which is why CI lists this -folder upstream of both. +The Trax.Effect conformance tests read `machines/` and `conformance/`, and the Trax.Cli byte-parity tests read +`machines/`, which is why CI lists this folder upstream of both. diff --git a/Trax.Api.StateMachine/conformance/canonical-wire.json b/Trax.Api.StateMachine/conformance/canonical-wire.json new file mode 100644 index 000000000..740770059 --- /dev/null +++ b/Trax.Api.StateMachine/conformance/canonical-wire.json @@ -0,0 +1,37 @@ +{ + "_comment": "The canonical wire (RFC 8785) of a snapshot's context: object keys sorted by UTF-16 code unit, numbers as ECMAScript Number::toString writes them, strings as JSON.stringify escapes them. Each case's context is parsed and serialized inside the envelope {\"machine\":\"m\",\"version\":1,\"state\":\"S\",\"context\":...}; 'wire' is the context part of the result. A context that a JSON parser cannot carry is given as UTF-16 code units instead: 'stringUnits' is the context {\"s\":}, 'keyUnits' the context {:1}. Every engine reads this file; add a vector here, not in one engine's tests.", + "envelope": {"machine":"m","version":1,"state":"S"}, + "cases": [ + {"name":"number 0.1","context":"{\"n\":0.1}","wire":"{\"n\":0.1}"}, + {"name":"number 1e21","context":"{\"n\":1e21}","wire":"{\"n\":1e+21}"}, + {"name":"number 1e20","context":"{\"n\":1e20}","wire":"{\"n\":100000000000000000000}"}, + {"name":"number 1e-7","context":"{\"n\":1e-7}","wire":"{\"n\":1e-7}"}, + {"name":"number 1e-6","context":"{\"n\":1e-6}","wire":"{\"n\":0.000001}"}, + {"name":"number 5e-324","context":"{\"n\":5e-324}","wire":"{\"n\":5e-324}"}, + {"name":"number 1.7976931348623157e308","context":"{\"n\":1.7976931348623157e308}","wire":"{\"n\":1.7976931348623157e+308}"}, + {"name":"number 1.2345678901234568e20","context":"{\"n\":1.2345678901234568e20}","wire":"{\"n\":123456789012345680000}"}, + {"name":"number 100","context":"{\"n\":100}","wire":"{\"n\":100}"}, + {"name":"number 100.0","context":"{\"n\":100.0}","wire":"{\"n\":100}"}, + {"name":"number -1.5","context":"{\"n\":-1.5}","wire":"{\"n\":-1.5}"}, + {"name":"number 2.5","context":"{\"n\":2.5}","wire":"{\"n\":2.5}"}, + {"name":"number 1","context":"{\"n\":1}","wire":"{\"n\":1}"}, + {"name":"number -0","context":"{\"n\":-0}","wire":"{\"n\":0}"}, + {"name":"number 3.4028235E+38","context":"{\"n\":3.4028235E+38}","wire":"{\"n\":3.4028235e+38}"}, + {"name":"string: non-ASCII stays literal","context":"{\"s\":\"café\"}","wire":"{\"s\":\"café\"}"}, + {"name":"string: an astral character stays literal","context":"{\"s\":\"😀\"}","wire":"{\"s\":\"😀\"}"}, + {"name":"string: a slash is not escaped","context":"{\"s\":\"a/b\"}","wire":"{\"s\":\"a/b\"}"}, + {"name":"string: a quote is escaped","context":"{\"s\":\"q\\\"x\"}","wire":"{\"s\":\"q\\\"x\"}"}, + {"name":"string: a backslash is escaped","context":"{\"s\":\"b\\\\s\"}","wire":"{\"s\":\"b\\\\s\"}"}, + {"name":"string: controls use short escapes, else lowercase hex","context":"{\"s\":\"\\u0001\\t\\n\\u001f\"}","wire":"{\"s\":\"\\u0001\\t\\n\\u001f\"}"}, + {"name":"string: backspace, form feed and carriage return use short escapes","context":"{\"s\":\"\\b\\f\\r\"}","wire":"{\"s\":\"\\b\\f\\r\"}"}, + {"name":"keys sort by UTF-16 code unit, integer-like ones too","context":"{\"b\":1,\"10\":\"x\",\"2\":\"y\"}","wire":"{\"10\":\"x\",\"2\":\"y\",\"b\":1}"}, + {"name":"nested keys sort the same way","context":"{\"o\":{\"9\":1,\"10\":2,\"a\":3}}","wire":"{\"o\":{\"10\":2,\"9\":1,\"a\":3}}"}, + {"name":"a key named __proto__ is kept","context":"{\"__proto__\":{\"x\":1},\"a\":2}","wire":"{\"__proto__\":{\"x\":1},\"a\":2}"}, + {"name":"string: a lone high surrogate is escaped","stringUnits":[55296],"wire":"{\"s\":\"\\ud800\"}"}, + {"name":"string: a lone low surrogate is escaped","stringUnits":[57343],"wire":"{\"s\":\"\\udfff\"}"}, + {"name":"string: a high surrogate at the end is escaped","stringUnits":[97,55357],"wire":"{\"s\":\"a\\ud83d\"}"}, + {"name":"string: a reversed pair is escaped","stringUnits":[56832,55357],"wire":"{\"s\":\"\\ude00\\ud83d\"}"}, + {"name":"string: a lone high surrogate before a pair is escaped","stringUnits":[55357,55357,56832],"wire":"{\"s\":\"\\ud83d😀\"}"}, + {"name":"key: a lone surrogate is escaped","keyUnits":[56319],"wire":"{\"\\udbff\":1}"} + ] +} diff --git a/Trax.Api.StateMachine/conformance/refusal-codes.json b/Trax.Api.StateMachine/conformance/refusal-codes.json new file mode 100644 index 000000000..e70e37553 --- /dev/null +++ b/Trax.Api.StateMachine/conformance/refusal-codes.json @@ -0,0 +1,45 @@ +{ + "_comment": "Every code a machine operation is refused with, in every engine, client and the GraphQL schema: one closed set, in this order. 'code' is the spelling on the wire; 'name' is the member name an engine gives it where its language names members. Only the code is contract.", + "codes": [ + {"name":"NoTransition","code":"NO_TRANSITION"}, + {"name":"GuardFailed","code":"GUARD_FAILED"}, + {"name":"InvalidContext","code":"INVALID_CONTEXT"}, + {"name":"InternalError","code":"INTERNAL_ERROR"}, + {"name":"InvalidInput","code":"INVALID_INPUT"}, + {"name":"OutcomeBound","code":"OUTCOME_BOUND"}, + {"name":"TimerBound","code":"TIMER_BOUND"}, + {"name":"TooLarge","code":"TOO_LARGE"}, + {"name":"DiscardNotAllowed","code":"DISCARD_NOT_ALLOWED"}, + {"name":"EffectInProgress","code":"EFFECT_IN_PROGRESS"}, + {"name":"Malformed","code":"MALFORMED"}, + {"name":"UnknownMachine","code":"UNKNOWN_MACHINE"}, + {"name":"VersionMismatch","code":"VERSION_MISMATCH"}, + {"name":"UnknownState","code":"UNKNOWN_STATE"}, + {"name":"Forbidden","code":"FORBIDDEN"}, + {"name":"RequestIdReused","code":"REQUEST_ID_REUSED"}, + {"name":"EffectBound","code":"EFFECT_BOUND"}, + {"name":"SaveNotAllowed","code":"SAVE_NOT_ALLOWED"}, + {"name":"ClientOutdated","code":"CLIENT_OUTDATED"}, + {"name":"ServerOutdated","code":"SERVER_OUTDATED"}, + {"name":"TooManyDrafts","code":"TOO_MANY_DRAFTS"}, + {"name":"InvalidRequestId","code":"INVALID_REQUEST_ID"}, + {"name":"Unauthenticated","code":"UNAUTHENTICATED"}, + {"name":"NoStart","code":"NO_START"}, + {"name":"SystemOwned","code":"SYSTEM_OWNED"}, + {"name":"NotFound","code":"NOT_FOUND"}, + {"name":"TooManySubscriptions","code":"TOO_MANY_SUBSCRIPTIONS"}, + {"name":"SchemaMismatch","code":"SCHEMA_MISMATCH"}, + {"name":"Conflict","code":"CONFLICT"}, + {"name":"NoEffect","code":"NO_EFFECT"}, + {"name":"DeliveryFailed","code":"DELIVERY_FAILED"}, + {"name":"ClientDivergence","code":"CLIENT_DIVERGENCE"}, + {"name":"StateReserved","code":"STATE_RESERVED"}, + {"name":"DraftInvoking","code":"DRAFT_INVOKING"}, + {"name":"DraftUnreadable","code":"DRAFT_UNREADABLE"}, + {"name":"DraftCommitted","code":"DRAFT_COMMITTED"}, + {"name":"DraftChanged","code":"DRAFT_CHANGED"}, + {"name":"InvokeLimitReached","code":"INVOKE_LIMIT_REACHED"}, + {"name":"InvokeForbidden","code":"INVOKE_FORBIDDEN"}, + {"name":"InvokeChainUserOwned","code":"INVOKE_CHAIN_USER_OWNED"} + ] +} diff --git a/Trax.Api.StateMachine/conformance/strict-input/cases.json b/Trax.Api.StateMachine/conformance/strict-input/cases.json new file mode 100644 index 000000000..55dce5a6c --- /dev/null +++ b/Trax.Api.StateMachine/conformance/strict-input/cases.json @@ -0,0 +1,144 @@ +{ + "_comment": "GENERATED by StrictInputGoldenTests from StrictInputMachine.Cases (Trax.Effect tests). Each case advances 'start' on 'trigger' through the machine in strict.ir.json, with 'validInput' whose member 'field' is set to 'value' (a case with no 'field' sends 'validInput' as is). 'expect' is the C# engine's answer, which is the CLR type's verdict. Regenerate with UPDATE_CONFORMANCE=1.", + "machine": "strict", + "start": "{\"machine\":\"strict\",\"version\":1,\"state\":\"Open\",\"context\":{}}", + "trigger": "Submit", + "validInput": {"amount":12.5,"at":"2026-10-12T10:00:00","atOffset":"2026-10-12T10:00:00.1234567+02:00","byColor":{"red":1},"byId":{"6f9619ff-8b86-d011-b42d-00c04fc964ff":1},"byNumber":{"1":"a"},"clock":"23:59:59.5","color":"darkGreen","day":"2024-02-29","huge":1,"hugeUnsigned":1,"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","initial":"x","link":"https://example.com/a?b=c","maybeColor":null,"owners":{"a":"00000000-0000-0000-0000-000000000000"},"palette":["red","sky-blue"],"patience":"PT5M","price":3,"qty":2,"ratio":0.25,"small":1.5,"wide":1e+300}, + "cases": [ + {"expect":{"outcome":"transitioned"},"name":"valid input"}, + {"expect":{"outcome":"transitioned"},"field":"color","name":"enum name","value":"red"}, + {"expect":{"outcome":"transitioned"},"field":"color","name":"enum camelCase name","value":"darkGreen"}, + {"expect":{"outcome":"transitioned"},"field":"color","name":"enum PascalCase name","value":"DarkGreen"}, + {"expect":{"outcome":"transitioned"},"field":"color","name":"enum member name","value":"sky-blue"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum C# name of a renamed member","value":"skyBlue"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum renamed member in upper case","value":"SKY-BLUE"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum renamed member in title case","value":"Sky-Blue"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"palette","name":"list of enums with a renamed one in upper case","value":["SKY-BLUE"]}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum unknown name","value":"purple"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum number as text","value":"1"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum empty","value":""}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"maybeColor","name":"nullable enum unknown","value":"purple"}, + {"expect":{"outcome":"transitioned"},"field":"maybeColor","name":"nullable enum name","value":"red"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"palette","name":"list of enums with an unknown one","value":["red","purple"]}, + {"expect":{"outcome":"transitioned"},"field":"id","name":"uuid upper case","value":"6F9619FF-8B86-D011-B42D-00C04FC964FF"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid without hyphens","value":"6f9619ff8b86d011b42d00c04fc964ff"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid in braces","value":"{6f9619ff-8b86-d011-b42d-00c04fc964ff}"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid not hex","value":"6f9619ff-8b86-d011-b42d-00c04fc964fg"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid prose","value":"not-a-guid"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid with Arabic-Indic digits","value":"٦f9619ff-8b86-d011-b42d-00c04fc964ff"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"owners","name":"dictionary of uuids with a bad one","value":{"a":"nope"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"total","name":"computed property sent","value":1}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"total","name":"computed property sent as what it computes","value":6}, + {"expect":{"outcome":"transitioned"},"field":"byNumber","name":"int key","value":{"-5":"a"}}, + {"expect":{"outcome":"transitioned"},"field":"byNumber","name":"int key at int max","value":{"2147483647":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key past int max","value":{"2147483648":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key not a number","value":{"x":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a fraction","value":{"1.0":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with an exponent","value":{"1e3":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a leading zero","value":{"01":"a"}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a space","value":{" 1":"a"}}, + {"expect":{"outcome":"transitioned"},"field":"byColor","name":"enum key","value":{"DarkGreen":1}}, + {"expect":{"outcome":"transitioned"},"field":"byColor","name":"enum key renamed","value":{"sky-blue":1}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byColor","name":"enum key renamed in upper case","value":{"SKY-BLUE":1}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byColor","name":"enum key unknown","value":{"purple":1}}, + {"expect":{"outcome":"transitioned"},"field":"byId","name":"guid key","value":{"6F9619FF-8B86-D011-B42D-00C04FC964FF":1}}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byId","name":"guid key without hyphens","value":{"6f9619ff8b86d011b42d00c04fc964ff":1}}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time Z","value":"2026-10-12T10:00:00Z"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time fraction","value":"2026-10-12T10:00:00.123Z"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time seven fraction digits and an offset","value":"2026-10-12T10:00:00.1234567+02:00"}, + {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time offset","value":"2026-10-12T10:00:00-14:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time offset too far","value":"2026-10-12T10:00:00+14:30"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time without seconds","value":"2026-10-12T10:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time with a space","value":"2026-10-12 10:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time eight fraction digits","value":"2026-10-12T10:00:00.12345678"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time February 30","value":"2026-02-30T10:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time hour 24","value":"2026-10-12T24:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time leap second","value":"2026-12-31T23:59:60Z"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time year zero","value":"0000-01-01T00:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time lower-case t","value":"2026-10-12t10:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time full-width digit","value":"2026-10-12T10:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time the least DateTime","value":"0001-01-01T00:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time the greatest DateTime","value":"9999-12-31T23:59:59.9999999"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time offset before year 1 in UTC","value":"0001-01-01T00:59:59+01:00"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time offset in year 1 in UTC","value":"0001-01-01T01:00:00+01:00"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time negative offset in year 1","value":"0001-01-01T00:00:00-01:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time offset past year 9999 in UTC","value":"9999-12-31T23:59:59.9999999-01:00"}, + {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time offset in year 9999 in UTC","value":"9999-12-31T23:59:59.9999999+01:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset before year 1 in UTC","value":"0001-01-01T00:00:00+01:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset past year 9999 in UTC","value":"9999-12-31T23:30:00-00:31"}, + {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset the least, in UTC","value":"0001-01-01T00:00:00Z"}, + {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset the least, with its offset","value":"0001-01-01T00:00:00+00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset with no offset on the first day","value":"0001-01-01T23:00:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset with no offset on the last day","value":"9999-12-31T00:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset with no offset on the second day","value":"0001-01-02T00:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset with no offset on the next-to-last day","value":"9999-12-30T23:59:59"}, + {"expect":{"outcome":"transitioned"},"field":"day","name":"date leap day","value":"2024-02-29"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date leap day of a common year","value":"2026-02-29"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date leap day of a century","value":"1900-02-29"}, + {"expect":{"outcome":"transitioned"},"field":"day","name":"date leap day of a 400th year","value":"2000-02-29"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date year zero","value":"0000-01-01"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date month 13","value":"2026-13-01"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date with a time","value":"2026-10-12T00:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"clock","name":"time midnight","value":"00:00:00"}, + {"expect":{"outcome":"transitioned"},"field":"clock","name":"time fraction","value":"12:30:15.1234567"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"clock","name":"time without seconds","value":"12:30"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"clock","name":"time minute 60","value":"12:60:00"}, + {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration","value":"P1DT2H30M"}, + {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration fraction","value":"PT0.5S"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration .NET form","value":"00:05:00"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration weeks","value":"P1W"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration negative","value":"-PT5M"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration Arabic-Indic digit","value":"PT٥M"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration past TimeSpan","value":"P999999999D"}, + {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration TimeSpan.MaxValue","value":"P10675199DT2H48M5.4775807S"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration one tick past TimeSpan.MaxValue","value":"P10675199DT2H48M5.4775808S"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri","value":"urn:isbn:0451450523"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri relative","value":"/a/b"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri with a space","value":"https://example.com/a b"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri unknown scheme only","value":"ab:"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri known scheme with no authority","value":"https:"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri one-letter scheme","value":"c:foo"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri unclosed IPv6 host","value":"http://[::1"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri IPv6 host and port","value":"http://[::1]:8080/a"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri IPv6 host with two double colons","value":"http://[1::2::3]/"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri IPv4-mapped IPv6 host","value":"http://[::ffff:1.2.3.4]/"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri dotted host with an octet past 255","value":"http://1.2.3.999/"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri port past 65535","value":"http://a:99999/"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri port 65535","value":"http://a:65535/a?b=c#d"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri empty host","value":"http:///a"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri host with an empty label","value":"http://a..b/"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri with a user","value":"http://u@a/"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri file","value":"file:///tmp/a"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri file with a UNC path","value":"file:////share/a"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri file with a drive","value":"file:///a:x"}, + {"expect":{"outcome":"transitioned"},"field":"link","name":"uri mailto","value":"mailto:a.b@example.com"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri mailto with no address","value":"mailto:a"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri net.pipe with a port","value":"net.pipe://a:1/"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri malformed escape","value":"http://a/%zz"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri non-ASCII","value":"http://a/é"}, + {"expect":{"outcome":"transitioned"},"field":"initial","name":"char","value":"é"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char empty","value":""}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char two letters","value":"ab"}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char astral","value":"😀"}, + {"expect":{"outcome":"transitioned"},"field":"amount","name":"decimal max","value":7.9e+28}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"amount","name":"decimal past max","value":1e+30}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"amount","name":"decimal past min","value":-1e+30}, + {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float max","value":3.4e+38}, + {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float max as System.Text.Json writes it","value":3.4028235e+38}, + {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float min as System.Text.Json writes it","value":-3.4028235e+38}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"ratio","name":"float just past its written max","value":3.4028236e+38}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"ratio","name":"float past max","value":3.5e+38}, + {"expect":{"outcome":"transitioned"},"field":"small","name":"half max","value":65504}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"small","name":"half past max","value":65505}, + {"expect":{"outcome":"transitioned"},"field":"wide","name":"double has no extra range","value":-1.7e+308}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 written with an exponent","value":1e+38}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 at 1e21","value":1e+21}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 below -1e21","value":-1e+21}, + {"expect":{"outcome":"transitioned"},"field":"huge","name":"int128 just under 1e21","value":999999999999999900000}, + {"expect":{"outcome":"transitioned"},"field":"huge","name":"int128 just over -1e21","value":-999999999999999900000}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 fraction","value":1.5}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"hugeUnsigned","name":"uint128 at 1e21","value":1e+21}, + {"expect":{"outcome":"transitioned"},"field":"hugeUnsigned","name":"uint128 just under 1e21","value":999999999999999900000}, + {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"hugeUnsigned","name":"uint128 negative","value":-1} + ] +} diff --git a/Trax.Api.StateMachine/conformance/strict-input/strict.ir.json b/Trax.Api.StateMachine/conformance/strict-input/strict.ir.json new file mode 100644 index 000000000..abaaabce9 --- /dev/null +++ b/Trax.Api.StateMachine/conformance/strict-input/strict.ir.json @@ -0,0 +1 @@ +{"committedStates":[],"context":{"Done":{"fields":[]},"Open":{"fields":[]}},"id":"strict","initialContext":{},"initialState":"Open","inputs":{"Submit":{"fields":[{"constraints":[],"maximum":7.922816251426433e+28,"minimum":-7.922816251426433e+28,"name":"amount","nullable":false,"type":"number"},{"constraints":[],"format":"date-time","name":"at","nullable":false,"type":"string"},{"constraints":[],"format":"date-time-offset","name":"atOffset","nullable":false,"type":"string"},{"constraints":[],"keys":{"enum":["red","darkGreen","sky-blue"],"enumExact":["sky-blue"],"nullable":false,"type":"string"},"name":"byColor","nullable":false,"type":"object","values":{"maximum":2147483647,"minimum":-2147483648,"nullable":false,"type":"integer"}},{"constraints":[],"keys":{"format":"uuid","nullable":false,"type":"string"},"name":"byId","nullable":false,"type":"object","values":{"maximum":2147483647,"minimum":-2147483648,"nullable":false,"type":"integer"}},{"constraints":[],"keys":{"maximum":2147483647,"minimum":-2147483648,"nullable":false,"type":"integer"},"name":"byNumber","nullable":false,"type":"object","values":{"nullable":false,"type":"string"}},{"constraints":[],"format":"time","name":"clock","nullable":false,"type":"string"},{"constraints":[],"enum":["red","darkGreen","sky-blue"],"enumExact":["sky-blue"],"name":"color","nullable":false,"type":"string"},{"constraints":[],"format":"date","name":"day","nullable":false,"type":"string"},{"constraints":[],"maximum":999999999999999900000,"minimum":-999999999999999900000,"name":"huge","nullable":false,"type":"integer"},{"constraints":[],"maximum":999999999999999900000,"minimum":0,"name":"hugeUnsigned","nullable":false,"type":"integer"},{"constraints":[],"format":"uuid","name":"id","nullable":false,"type":"string"},{"constraints":[],"format":"char","name":"initial","nullable":false,"type":"string"},{"constraints":[],"format":"uri","name":"link","nullable":false,"type":"string"},{"constraints":[],"enum":["red","darkGreen","sky-blue"],"enumExact":["sky-blue"],"name":"maybeColor","nullable":true,"type":"string"},{"constraints":[],"name":"owners","nullable":false,"type":"object","values":{"format":"uuid","nullable":false,"type":"string"}},{"constraints":[{"field":"palette","rule":"arrayOf","source":"input","type":"string"}],"name":"palette","nullable":false,"type":"array","values":{"enum":["red","darkGreen","sky-blue"],"enumExact":["sky-blue"],"nullable":false,"type":"string"}},{"constraints":[],"format":"duration","name":"patience","nullable":false,"type":"string"},{"constraints":[],"maximum":2147483647,"minimum":-2147483648,"name":"price","nullable":false,"type":"integer"},{"constraints":[],"maximum":2147483647,"minimum":-2147483648,"name":"qty","nullable":false,"type":"integer"},{"constraints":[],"maximum":3.4028235e+38,"minimum":-3.4028235e+38,"name":"ratio","nullable":false,"type":"number"},{"constraints":[],"maximum":65504,"minimum":-65504,"name":"small","nullable":false,"type":"number"},{"constraints":[],"name":"wide","nullable":false,"type":"number"}]}},"states":["Done","Open"],"transitions":[{"from":"Open","to":"Done","trigger":"Submit"}],"triggers":["Check","Submit"],"version":1} diff --git a/Trax.Api.StateMachine/src/canonicalization.conformance.test.ts b/Trax.Api.StateMachine/src/canonicalization.conformance.test.ts index fa19ae2a2..5bd62044e 100644 --- a/Trax.Api.StateMachine/src/canonicalization.conformance.test.ts +++ b/Trax.Api.StateMachine/src/canonicalization.conformance.test.ts @@ -1,11 +1,11 @@ +import fs from "node:fs"; import { describe, expect, it } from "vitest"; +import { conformanceFile } from "./fixtures"; import { turnstileCore } from "./machines/turnstile/turnstile"; import type { Snapshot } from "./types"; -// RFC 8785 (JCS) requires numbers and strings to serialize exactly as ECMAScript JSON.stringify. The TS twin -// delegates value emission to JSON.stringify, so it is compliant by construction; this locks that it stays so, -// and the vectors are identical to the C# CanonicalizationConformanceTests, keeping both runtimes pinned to -// the same table. +// RFC 8785 (JCS) requires numbers and strings to serialize exactly as ECMAScript JSON.stringify. The vectors are +// conformance/canonical-wire.json, which every engine reads; add one there, not here. const wire = (context: Record) => turnstileCore.serialize({ machine: "m", @@ -16,52 +16,26 @@ const wire = (context: Record) => const envelope = (ctx: string) => `{"machine":"m","version":1,"state":"S","context":${ctx}}`; -describe("canonical wire conformance (RFC 8785 / ECMAScript)", () => { - it.each<[number, string]>([ - [0.1, "0.1"], - [1e21, "1e+21"], - [1e20, "100000000000000000000"], - [1e-7, "1e-7"], - [1e-6, "0.000001"], - [5e-324, "5e-324"], - [1.7976931348623157e308, "1.7976931348623157e+308"], - [1.2345678901234568e20, "123456789012345680000"], - [100, "100"], - [-1.5, "-1.5"], - [2.5, "2.5"], - [1, "1"], - [-0, "0"], - ])("formats number %p as %s", (value, expected) => { - expect(wire({ n: value })).toBe(envelope(`{"n":${expected}}`)); - }); +type Vector = { name: string; context?: string; stringUnits?: number[]; keyUnits?: number[]; wire: string }; - it("keeps non-ASCII and astral characters literal", () => { - expect(wire({ s: "café" })).toBe(envelope('{"s":"café"}')); - expect(wire({ s: "😀" })).toBe(envelope('{"s":"😀"}')); - }); +const vectors = ( + JSON.parse(fs.readFileSync(conformanceFile("canonical-wire.json"), "utf8")) as { cases: Vector[] } +).cases; - it("uses short escapes and lowercase hex for control characters", () => { - // Input holds U+0001, tab, newline, U+001F. The backslash in the expected is built from its code point - // so the literal escape text is not written into the source. - const bs = String.fromCharCode(0x5c); - const input = String.fromCharCode(0x01, 0x09, 0x0a, 0x1f); - expect(wire({ s: input })).toBe( - envelope(`{"s":"${bs}u0001${bs}t${bs}n${bs}u001f"}`), - ); - }); +const contextOf = (v: Vector): Record => { + if (v.context !== undefined) return JSON.parse(v.context) as Record; + if (v.stringUnits) return { s: String.fromCharCode(...v.stringUnits) }; + if (v.keyUnits) return { [String.fromCharCode(...v.keyUnits)]: 1 }; + throw new Error(`${v.name}: no context`); +}; - it("orders integer-like keys by UTF-16 code unit, as RFC 8785 and the C# wire do", () => { - expect(wire({ b: 1, "10": "x", "2": "y" })).toBe( - envelope('{"10":"x","2":"y","b":1}'), - ); - expect(wire({ o: { "9": 1, "10": 2, a: 3 } })).toBe( - envelope('{"o":{"10":2,"9":1,"a":3}}'), - ); +describe("canonical wire conformance (RFC 8785 / ECMAScript)", () => { + it("has vectors", () => { + expect(vectors.length).toBeGreaterThan(20); }); - it("keeps a context key named __proto__", () => { - const context = JSON.parse('{"__proto__":{"x":1},"a":2}'); - expect(wire(context)).toBe(envelope('{"__proto__":{"x":1},"a":2}')); + it.each(vectors.map((v) => [v.name, v] as const))("%s", (_, v) => { + expect(wire(contextOf(v))).toBe(envelope(v.wire)); }); }); diff --git a/Trax.Api.StateMachine/src/fixtures.ts b/Trax.Api.StateMachine/src/fixtures.ts index 592a02d07..b1ee1e29e 100644 --- a/Trax.Api.StateMachine/src/fixtures.ts +++ b/Trax.Api.StateMachine/src/fixtures.ts @@ -64,3 +64,6 @@ export function rehydrateInput(fixture: Record): string { if (typeof fixture.raw === "string") return fixture.raw; return JSON.stringify(fixture.json); } +/** A file of the engine-neutral conformance kit, `conformance/` beside `machines/`. */ +export const conformanceFile = (...parts: string[]): string => + path.join(path.dirname(machinesRoot()), "conformance", ...parts); diff --git a/Trax.Api.StateMachine/src/refusalCodes.conformance.test.ts b/Trax.Api.StateMachine/src/refusalCodes.conformance.test.ts new file mode 100644 index 000000000..65ffeab8f --- /dev/null +++ b/Trax.Api.StateMachine/src/refusalCodes.conformance.test.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { conformanceFile } from "./fixtures"; +import { RefusalCode } from "./results"; + +// conformance/refusal-codes.json is the one closed set of refusal codes; the C# RefusalCodeParityTests holds the +// C# enum (and so the GraphQL enum) equal to it, and this holds the package's RefusalCode equal to it. +describe("refusal codes", () => { + it("are the shared registry's, in its order", () => { + const registry = ( + JSON.parse(fs.readFileSync(conformanceFile("refusal-codes.json"), "utf8")) as { + codes: { name: string; code: string }[]; + } + ).codes; + + expect(Object.entries(RefusalCode).map(([name, code]) => ({ name, code }))).toEqual(registry); + }); +}); diff --git a/Trax.Api.StateMachine/src/rules/formats.test.ts b/Trax.Api.StateMachine/src/rules/formats.test.ts index 66b606c85..7c155420e 100644 --- a/Trax.Api.StateMachine/src/rules/formats.test.ts +++ b/Trax.Api.StateMachine/src/rules/formats.test.ts @@ -1,70 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { isOneOf, matchesFormat } from './formats'; +import { isOneOf } from './formats'; import { validateInput, type ContextSchema } from './interpreter'; // A string field is as strict as its C# type: an enum's names, and the formats of a Guid, a date, a time, a -// duration, a URI and a char. The mirror of the C# SchemaFormatTests; the C# StrictInputParityTests sends its whole -// case table through both engines. +// duration, a URI and a char. Each format's verdicts are in conformance/strict-input/, written from the CLR and +// replayed by strictInput.conformance.test.ts; these are the checks that need a schema built by hand. describe('formats', () => { - it.each([ - ['uuid', '6F9619FF-8B86-D011-B42D-00C04FC964FF', true], - ['uuid', '6f9619ff8b86d011b42d00c04fc964ff', false], - ['uuid', '{6f9619ff-8b86-d011-b42d-00c04fc964ff}', false], - ['date-time', '2026-10-12T10:00:00.1234567+02:00', true], - ['date-time', '2026-10-12T10:00:00Z', true], - ['date-time', '2026-10-12T10:00:00+14:30', false], - ['date-time', '2026-02-30T10:00:00', false], - ['date-time', '2026-12-31T23:59:60Z', false], - ['date-time', '2026-10-12T10:00:00', false], - ['date-time', '0001-01-01T00:00:00', true], - ['date-time', '0001-01-01T00:59:59+01:00', false], - ['date-time', '9999-12-31T23:59:59.9999999-01:00', false], - ['date-time', '9999-12-31T23:59:59.9999999+01:00', true], - ['date-time-offset', '0001-01-01T00:00:00Z', true], - ['date-time-offset', '0001-01-01T00:00:00+01:00', false], - ['date-time-offset', '0001-01-01T23:00:00', false], - ['date-time-offset', '9999-12-31T00:00:00', false], - ['date-time-offset', '9999-12-30T23:59:59', true], - ['date', '2000-02-29', true], - ['date', '1900-02-29', false], - ['date', '0000-01-01', false], - ['time', '23:59:59.5', true], - ['time', '12:30', false], - ['duration', 'P1DT2H30M', true], - ['duration', 'PT٥M', false], - ['duration', '00:05:00', false], - ['duration', 'P10675199DT2H48M5.4775807S', true], - ['duration', 'P10675199DT2H48M5.4775808S', false], - ['uri', 'urn:isbn:0451450523', true], - ['uri', '/a/b', false], - ['uri', 'https://example.com/a b', false], - ['uri', 'ab:', true], - ['uri', 'https:', false], - ['uri', 'c:foo', false], - ['uri', 'http://[::1', false], - ['uri', 'http://[::1]:8080/a', true], - ['uri', 'http://[1::2::3]/', false], - ['uri', 'http://[::ffff:1.2.3.4]/', true], - ['uri', 'http://a:99999/', false], - ['uri', 'http://a:65535/a?b=c#d', true], - ['uri', 'http://a..b/', false], - ['uri', 'http://1.2.3.999/', false], - ['uri', 'http://u@a/', false], - ['uri', 'file:///tmp/a', true], - ['uri', 'file:////share/a', false], - ['uri', 'file:///a:x', false], - ['uri', 'mailto:a.b@example.com', true], - ['uri', 'mailto:a', false], - ['uri', 'net.pipe://a:1/', false], - ['uri', 'http://a/%zz', false], - ['char', 'é', true], - ['char', '😀', false], - ['char', '', false], - ])('%s %j is %s', (format, value, valid) => { - expect(matchesFormat(format, value)).toBe(valid); - }); - it('compares enum names ignoring ASCII case only, and a renamed member exactly', () => { const names = ['red', 'darkGreen', 'sky-blue']; const exact = ['sky-blue']; diff --git a/Trax.Api.StateMachine/src/strictInput.conformance.test.ts b/Trax.Api.StateMachine/src/strictInput.conformance.test.ts new file mode 100644 index 000000000..8f7e62498 --- /dev/null +++ b/Trax.Api.StateMachine/src/strictInput.conformance.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { conformanceFile } from "./fixtures"; +import { SnapshotMachine } from "./machine"; +import { machineFromIr, type IrDocument } from "./rules/irMachine"; + +// conformance/strict-input/ is written by the C# StrictInputGoldenTests: each value of one field of a strict +// input, and the verdict its CLR type gives it. This engine must give the same one. The table is data, so an +// engine in any other language replays the same file. + +type Case = { + name: string; + field?: string; + value?: unknown; + expect: { outcome: "transitioned" } | { outcome: "rejected"; reason: string }; +}; + +const golden = JSON.parse( + fs.readFileSync(conformanceFile("strict-input", "cases.json"), "utf8"), +) as { start: string; trigger: string; validInput: Record; cases: Case[] }; +const ir = JSON.parse( + fs.readFileSync(conformanceFile("strict-input", "strict.ir.json"), "utf8"), +) as IrDocument; +const machine = new SnapshotMachine(machineFromIr(ir)); + +describe("strict input conformance", () => { + it("has cases", () => { + expect(golden.cases.length).toBeGreaterThan(100); + }); + + it.each(golden.cases.map((c) => [c.name, c] as const))("%s", (_, c) => { + const start = machine.rehydrate(golden.start); + if (start.result !== "ok") throw new Error(`start does not rehydrate: ${start.message}`); + const input: Record = structuredClone(golden.validInput); + if (c.field !== undefined) input[c.field] = c.value; + + const result = machine.advance(start.snapshot, golden.trigger, input); + + expect( + result.outcome === "transitioned" + ? { outcome: "transitioned" } + : { outcome: "rejected", reason: result.reason }, + ).toEqual(c.expect); + }); +}); diff --git a/Trax.Docs/sdk-reference/statemachine-api/result-codes.md b/Trax.Docs/sdk-reference/statemachine-api/result-codes.md index ef76626ab..bf6a27231 100644 --- a/Trax.Docs/sdk-reference/statemachine-api/result-codes.md +++ b/Trax.Docs/sdk-reference/statemachine-api/result-codes.md @@ -25,7 +25,8 @@ snake case (`GUARD_FAILED`), the spelling GraphQL gives the enum: `RefusalCode.G `RefusalCode.GuardFailed` in TypeScript. `RefusalCodes.TryParse` also reads the kebab-case spelling the codes had before (`guard-failed`), so a request id an older node recorded still replays. `RefusalCodes.All` lists the members in order, and `RefusalCodeJsonConverter`, which the enum carries, writes and reads the wire spelling in -`System.Text.Json`. `RefusalCodeParityTests` holds the C# and TypeScript sets equal. +`System.Text.Json`. The set is also written as data, `Trax.Api.StateMachine/conformance/refusal-codes.json`, and +every engine checks itself against that file, so the C# and TypeScript sets are equal member for member and in order. ## Members diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs index b78e0505b..a7f51e0a7 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/RefusalCodeParityTests.cs @@ -1,6 +1,6 @@ using System.Reflection; using System.Text.Json; -using System.Text.RegularExpressions; +using System.Text.Json.Nodes; using AwesomeAssertions; using Trax.Effect.StateMachine.Persistence; using Trax.Effect.StateMachine.Tests.Helpers; @@ -10,9 +10,10 @@ namespace Trax.Effect.StateMachine.Tests.Conformance; /// /// A refusal code is one closed set, , that both engines, the GraphQL schema and the /// TypeScript package hold. A code that is a free string somewhere reaches no client type, so every public member -/// that carries one is typed by the enum, and the TypeScript package's list equals it member for member. +/// that carries one is typed by the enum, and the enum equals conformance/refusal-codes.json member for +/// member, which the TypeScript package and every other engine check themselves against too. /// -public partial class RefusalCodeParityTests +public class RefusalCodeParityTests { [Test] public void Every_public_code_or_reason_is_a_RefusalCode() @@ -72,36 +73,22 @@ public void A_code_an_older_node_recorded_in_kebab_case_still_reads() } [Test] - public void The_TypeScript_package_exports_the_same_codes() + public void The_shared_registry_lists_the_same_codes_in_the_same_order() { - var resultsTs = Path.Combine( - Directory.GetParent(FixturePaths.MachinesRoot)!.FullName, - "src", - "results.ts" - ); - var block = RefusalCodeBlock().Match(File.ReadAllText(resultsTs)); - block - .Success.Should() - .BeTrue("results.ts declares `export const RefusalCode = { ... } as const;`"); - - var ts = TsMember() - .Matches(block.Groups["body"].Value) - .Select(m => (Name: m.Groups["name"].Value, Wire: m.Groups["wire"].Value)) + var registry = JsonNode.Parse( + File.ReadAllText(Path.Combine(FixturePaths.ConformanceRoot, "refusal-codes.json")) + )!["codes"]! + .AsArray() + .Select(c => + (Name: c!["name"]!.GetValue(), Wire: c["code"]!.GetValue()) + ) .ToList(); - ts.Should() + registry + .Should() .Equal( RefusalCodes.All.Select(c => (Name: c.ToString(), Wire: c.ToWire())), - "the TypeScript RefusalCode and the C# enum are one set, in one order" + "conformance/refusal-codes.json is the one set every engine and client checks itself against" ); } - - [GeneratedRegex( - @"export const RefusalCode = \{(?.*?)\} as const;", - RegexOptions.Singleline - )] - private static partial Regex RefusalCodeBlock(); - - [GeneratedRegex(@"(?[A-Z][A-Za-z]*): ['""](?[A-Z_]+)['""]")] - private static partial Regex TsMember(); } diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs new file mode 100644 index 000000000..621b8ca3d --- /dev/null +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs @@ -0,0 +1,145 @@ +using System.Text; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Trax.Effect.StateMachine.Tests.Fakes; +using Trax.Effect.StateMachine.Tests.Helpers; + +namespace Trax.Effect.StateMachine.Tests.Conformance; + +/// +/// , written as data any engine replays: the machine's IR and, for each value, +/// the input it sends and the C# engine's answer. The verdicts are the CLR's ( +/// holds the engine to them), so an engine in another language that replays conformance/strict-input/ checks +/// its format, enum and range rules against the CLR without running .NET or node. Regenerate both files deliberately +/// with UPDATE_CONFORMANCE=1. +/// +public class StrictInputGoldenTests +{ + private static string Dir => Path.Combine(FixturePaths.ConformanceRoot, "strict-input"); + + private static string IrFile => Path.Combine(Dir, "strict.ir.json"); + + private static string CasesFile => Path.Combine(Dir, "cases.json"); + + [Test] + public void The_committed_IR_is_what_the_exporter_writes() + { + var exported = IrExporter.Export(StrictInputMachine.Built) + "\n"; + Update(IrFile, exported); + + File.ReadAllText(IrFile) + .Should() + .Be(exported, "regenerate it deliberately with UPDATE_CONFORMANCE=1"); + } + + [Test] + public void The_committed_cases_are_what_the_engine_answers() + { + var written = Write(); + Update(CasesFile, written); + + File.ReadAllText(CasesFile) + .Should() + .Be(written, "regenerate it deliberately with UPDATE_CONFORMANCE=1"); + } + + [Test] + public void Replaying_the_committed_file_gives_its_answers() + { + // The file stores numbers as the canonical wire writes them (3.4028235e+38, not 3.4028235E+38), so it is + // replayed as read, not trusted to mean what the C# literals did. + var golden = (JsonObject)JsonNode.Parse(File.ReadAllText(CasesFile))!; + var start = StrictInputMachine.Machine.Rehydrate(golden["start"]!.GetValue()); + var snapshot = start.Should().BeOfType().Which.Snapshot; + var trigger = golden["trigger"]!.GetValue(); + + var diffs = new List(); + foreach (var node in golden["cases"]!.AsArray()) + { + var @case = (JsonObject)node!; + var input = (JsonObject)golden["validInput"]!.DeepClone(); + if (@case["field"] is { } field) + input[field.GetValue()] = @case["value"]?.DeepClone(); + var expect = Answer(StrictInputMachine.Machine.Advance(snapshot, trigger, input)); + if (expect.ToJsonString() != @case["expect"]!.ToJsonString()) + diffs.Add( + $"{@case["name"]}: file says {@case["expect"]!.ToJsonString()}, engine {expect.ToJsonString()}" + ); + } + + diffs.Should().BeEmpty(string.Join("\n", diffs)); + } + + private static string Write() + { + var start = StrictInputMachine.Open(); + var trigger = nameof(StrictTrigger.Submit); + var valid = StrictInputMachine.ValidInput(); + + // One case per line, each line canonical JSON, so a changed verdict is a one-line diff. + var lines = StrictInputMachine + .Cases.Select(c => + CanonicalJson.Serialize( + new JsonObject + { + ["name"] = c.Name, + ["field"] = c.Field, + ["value"] = c.Value?.DeepClone(), + ["expect"] = Answer( + StrictInputMachine.Machine.Advance(start, trigger, c.Input()) + ), + } + ) + ) + .Prepend( + CanonicalJson.Serialize( + new JsonObject + { + ["name"] = "valid input", + ["expect"] = Answer( + StrictInputMachine.Machine.Advance(start, trigger, valid.DeepClone()) + ), + } + ) + ); + + var sb = new StringBuilder(); + sb.Append("{\n"); + sb.Append( + " \"_comment\": \"GENERATED by StrictInputGoldenTests from StrictInputMachine.Cases (Trax.Effect tests). " + + "Each case advances 'start' on 'trigger' through the machine in strict.ir.json, with 'validInput' " + + "whose member 'field' is set to 'value' (a case with no 'field' sends 'validInput' as is). 'expect' is " + + "the C# engine's answer, which is the CLR type's verdict. Regenerate with UPDATE_CONFORMANCE=1.\",\n" + ); + sb.Append(" \"machine\": \"strict\",\n"); + sb.Append(" \"start\": ") + .Append(CanonicalJson.Serialize(StrictInputMachine.Machine.Serialize(start))) + .Append(",\n"); + sb.Append(" \"trigger\": ").Append(CanonicalJson.Serialize(trigger)).Append(",\n"); + sb.Append(" \"validInput\": ").Append(CanonicalJson.Serialize(valid)).Append(",\n"); + sb.Append(" \"cases\": [\n "); + sb.Append(string.Join(",\n ", lines)); + sb.Append("\n ]\n}\n"); + return sb.ToString(); + } + + private static JsonObject Answer(AdvanceResult result) => + result switch + { + AdvanceResult.Transitioned => new JsonObject { ["outcome"] = "transitioned" }, + AdvanceResult.Rejected r => new JsonObject + { + ["outcome"] = "rejected", + ["reason"] = r.Reason.ToWire(), + }, + _ => throw new InvalidOperationException($"Unexpected advance result {result}."), + }; + + private static void Update(string path, string content) + { + if (Environment.GetEnvironmentVariable("UPDATE_CONFORMANCE") != "1") + return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } +} diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Fakes/StrictInputMachine.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Fakes/StrictInputMachine.cs index 4e00b22f4..d38ae1d9f 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Fakes/StrictInputMachine.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Fakes/StrictInputMachine.cs @@ -260,6 +260,11 @@ is AdvanceResult.Rejected r // or an offset of at most fourteen hours. new("date-time Z", "at", "2026-10-12T10:00:00Z"), new("date-time fraction", "at", "2026-10-12T10:00:00.123Z"), + new( + "date-time seven fraction digits and an offset", + "at", + "2026-10-12T10:00:00.1234567+02:00" + ), new("date-time offset", "atOffset", "2026-10-12T10:00:00-14:00"), new("date-time offset too far", "atOffset", "2026-10-12T10:00:00+14:30"), new( @@ -317,6 +322,7 @@ is AdvanceResult.Rejected r new("date leap day of a common year", "day", "2026-02-29"), new("date leap day of a century", "day", "1900-02-29"), new("date leap day of a 400th year", "day", "2000-02-29"), + new("date year zero", "day", "0000-01-01"), new("date month 13", "day", "2026-13-01"), new("date with a time", "day", "2026-10-12T00:00:00"), // A time is HH:mm:ss with at most seven fraction digits. @@ -359,12 +365,16 @@ is AdvanceResult.Rejected r new("uri one-letter scheme", "link", "c:foo"), new("uri unclosed IPv6 host", "link", "http://[::1"), new("uri IPv6 host and port", "link", "http://[::1]:8080/a"), + new("uri IPv6 host with two double colons", "link", "http://[1::2::3]/"), + new("uri IPv4-mapped IPv6 host", "link", "http://[::ffff:1.2.3.4]/"), + new("uri dotted host with an octet past 255", "link", "http://1.2.3.999/"), new("uri port past 65535", "link", "http://a:99999/"), new("uri port 65535", "link", "http://a:65535/a?b=c#d"), new("uri empty host", "link", "http:///a"), new("uri host with an empty label", "link", "http://a..b/"), new("uri with a user", "link", "http://u@a/", Stricter: "the grammar takes no user"), new("uri file", "link", "file:///tmp/a"), + new("uri file with a UNC path", "link", "file:////share/a"), new("uri file with a drive", "link", "file:///a:x"), new("uri mailto", "link", "mailto:a.b@example.com"), new( diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs index fff8ee542..d2f661fe1 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/FixturePaths.cs @@ -26,6 +26,13 @@ public static string DifferentialFile(string machine) => public static string IrFile(string machine) => Path.Combine(MachinesRoot, machine, $"{machine}.ir.json"); + /// + /// The engine-neutral conformance kit beside machines/: tables and cases any engine, in any language, + /// replays. + /// + public static string ConformanceRoot => + Path.Combine(Directory.GetParent(MachinesRoot)!.FullName, "conformance"); + /// The shared migration golden for a machine: stored older-version snapshots and the exact /// canonical wire each must become after forward migration. public static string MigrationFile(string machine) => diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/CanonicalizationConformanceTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/CanonicalizationConformanceTests.cs index ffa2da019..dd10e1382 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/CanonicalizationConformanceTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/UnitTests/CanonicalizationConformanceTests.cs @@ -1,14 +1,15 @@ using System.Text.Json.Nodes; using AwesomeAssertions; using Trax.Effect.StateMachine.Tests.Fakes; +using Trax.Effect.StateMachine.Tests.Helpers; namespace Trax.Effect.StateMachine.Tests.UnitTests; /// /// The canonical wire must match RFC 8785 (JCS) byte-for-byte, which for numbers and strings means matching -/// ECMAScript JSON.stringify exactly, since that is what the TypeScript twin emits. The expected -/// values here are the ECMAScript reference outputs (produced by JSON.stringify); a divergence is a -/// cross-runtime parity break, the precise failure the differential exists to catch. +/// ECMAScript JSON.stringify exactly, since that is what the TypeScript twin emits. The vectors are +/// conformance/canonical-wire.json, which every engine reads; a divergence is a cross-runtime parity break, +/// the precise failure the differential exists to catch. /// public class CanonicalizationConformanceTests { @@ -26,116 +27,40 @@ private static string Wire(JsonObject context) => private static string Envelope(string context) => "{\"machine\":\"m\",\"version\":1,\"state\":\"S\",\"context\":" + context + "}"; - [TestCase(0.1, "0.1")] - [TestCase(1e21, "1e+21")] - [TestCase(1e20, "100000000000000000000")] - [TestCase(1e-7, "1e-7")] - [TestCase(1e-6, "0.000001")] - [TestCase(5e-324, "5e-324")] - [TestCase(1.7976931348623157e308, "1.7976931348623157e+308")] - [TestCase(1.2345678901234568e20, "123456789012345680000")] - [TestCase(100.0, "100")] - [TestCase(-1.5, "-1.5")] - [TestCase(2.5, "2.5")] - [TestCase(1.0, "1")] - [TestCase(-0.0, "0")] - public void Serialize_formats_numbers_per_ECMAScript(double value, string expected) + public static IEnumerable Vectors() { - Wire(new JsonObject { ["n"] = value }).Should().Be(Envelope("{\"n\":" + expected + "}")); - } - - [TestCase("café", "café")] - [TestCase("a/b", "a/b")] - [TestCase("q\"x", "q\\\"x")] - [TestCase("b\\s", "b\\\\s")] - public void Serialize_escapes_strings_like_JSON_stringify(string value, string expectedInner) - { - Wire(new JsonObject { ["s"] = value }) - .Should() - .Be(Envelope("{\"s\":\"" + expectedInner + "\"}")); + var file = Path.Combine(FixturePaths.ConformanceRoot, "canonical-wire.json"); + foreach (var node in JsonNode.Parse(File.ReadAllText(file))!["cases"]!.AsArray()) + { + var vector = (JsonObject)node!; + yield return new TestCaseData(vector).SetArgDisplayNames( + vector["name"]!.GetValue() + ); + } } [Test] - public void Serialize_uses_short_escapes_and_lowercase_hex_for_controls() - { - // Input is U+0001, tab, newline, U+001F. Expected: short escapes where they exist, else lowercase - // hex. The backslash is built from its code point so the literal escape text is not in the source. - var input = new string([(char)0x01, (char)0x09, (char)0x0A, (char)0x1F]); - var bs = (char)0x5C; - var expectedInner = $"{bs}u0001{bs}t{bs}n{bs}u001f"; - Wire(new JsonObject { ["s"] = input }) - .Should() - .Be(Envelope("{\"s\":\"" + expectedInner + "\"}")); - } + public void The_shared_vectors_are_there() => Vectors().Should().HaveCountGreaterThan(20); - [Test] - public void Serialize_uses_the_backspace_formfeed_and_carriage_return_short_escapes() + [TestCaseSource(nameof(Vectors))] + public void Serialize_writes_the_shared_vector(JsonObject vector) { - // U+0008, U+000C, U+000D have JSON.stringify short escapes \b \f \r (the control escapes the test - // above does not exercise). Backslash built from its code point so the escape text is not in source. - var input = new string([(char)0x08, (char)0x0C, (char)0x0D]); - var bs = (char)0x5C; - var expectedInner = $"{bs}b{bs}f{bs}r"; - Wire(new JsonObject { ["s"] = input }) - .Should() - .Be(Envelope("{\"s\":\"" + expectedInner + "\"}")); + Wire(ContextOf(vector)).Should().Be(Envelope(vector["wire"]!.GetValue())); } - [Test] - public void Serialize_keeps_astral_characters_literal() + // A lone surrogate has no UTF-8 encoding and System.Text.Json refuses to read one, so the file gives those + // strings as UTF-16 code units. + private static JsonObject ContextOf(JsonObject vector) { - Wire(new JsonObject { ["s"] = "😀" }).Should().Be(Envelope("{\"s\":\"😀\"}")); + if (vector["context"] is { } context) + return (JsonObject)JsonNode.Parse(context.GetValue())!; + if (vector["stringUnits"] is JsonArray stringUnits) + return new JsonObject { ["s"] = FromUnits(stringUnits) }; + if (vector["keyUnits"] is JsonArray keyUnits) + return new JsonObject { [FromUnits(keyUnits)] = 1 }; + throw new InvalidOperationException($"{vector["name"]}: no context"); } - // A lone surrogate has no UTF-8 encoding, so JSON.stringify (ES2019) writes it as a lowercase \uxxxx - // escape, while a well-formed pair stays literal. The inputs are code units, because a C# string literal - // holding a lone surrogate is easy to mangle in an editor. - [TestCase(new[] { 0xD800 }, "\\ud800")] - [TestCase(new[] { 0xDFFF }, "\\udfff")] - [TestCase(new[] { 0x61, 0xD83D }, "a\\ud83d")] - [TestCase(new[] { 0xDE00, 0xD83D }, "\\ude00\\ud83d")] - [TestCase(new[] { 0xD83D, 0xD83D, 0xDE00 }, "\\ud83d😀")] - public void Serialize_escapes_lone_surrogates_like_JSON_stringify( - int[] codeUnits, - string expectedInner - ) - { - var input = new string(codeUnits.Select(u => (char)u).ToArray()); - Wire(new JsonObject { ["s"] = input }) - .Should() - .Be(Envelope("{\"s\":\"" + expectedInner + "\"}")); - } - - [Test] - public void Serialize_escapes_a_lone_surrogate_in_a_key() - { - Wire(new JsonObject { [new string((char)0xDBFF, 1)] = 1 }) - .Should() - .Be(Envelope("{\"\\udbff\":1}")); - } - - [Test] - public void Serialize_orders_integer_like_keys_by_UTF16_code_unit() - { - // RFC 8785 sorts every key by code unit. A JavaScript object would enumerate "2" before "10", so the - // TypeScript twin must not rely on insertion into a plain object to produce this order. - Wire( - new JsonObject - { - ["b"] = 1, - ["10"] = "x", - ["2"] = "y", - } - ) - .Should() - .Be(Envelope("{\"10\":\"x\",\"2\":\"y\",\"b\":1}")); - } - - [Test] - public void Serialize_keeps_a_key_named___proto__() - { - Wire(new JsonObject { ["__proto__"] = 1, ["a"] = 2 }) - .Should() - .Be(Envelope("{\"__proto__\":1,\"a\":2}")); - } + private static string FromUnits(JsonArray units) => + new(units.Select(u => (char)u!.GetValue()).ToArray()); } From b40248c4028eb841d16b3dc61939bd37f0040625 Mon Sep 17 00:00:00 2001 From: Theauxm <43664045+Theauxm@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:00:54 -0600 Subject: [PATCH 3/5] test(machines): write the IR format as a JSON Schema both engines check The IR was defined by the exporter and the TypeScript interpreter, and described in prose on the IR format page. An engine in another language could only be written by reading the TS code. conformance/ir.schema.json (draft 2020-12) now states it, with every object closed so an unknown field is refused rather than ignored. vitest validates every committed IR against it and checks that it refuses an extra field, a misspelt rule member, an unknown rule kind and an output on a non-done outcome. The C# IrSchemaTests exports every declarative test machine and validates it through the same validator (tools/conformance/ir-schema.mjs, over node, ajv as a dev dependency), so a field the exporter starts writing fails until the schema names it. The IR format page says the schema is normative. --- Trax.Api.StateMachine/AGENTS.md | 2 +- .../conformance/ir.schema.json | 970 ++++++++++++++++++ Trax.Api.StateMachine/package-lock.json | 59 ++ Trax.Api.StateMachine/package.json | 1 + .../src/irSchema.conformance.test.ts | 58 ++ .../tools/conformance/ir-schema.mjs | 39 + .../statemachine-api/ir-format.md | 8 + .../Conformance/IrSchemaTests.cs | 95 ++ .../Helpers/TwinEngine.cs | 33 +- 9 files changed, 1257 insertions(+), 8 deletions(-) create mode 100644 Trax.Api.StateMachine/conformance/ir.schema.json create mode 100644 Trax.Api.StateMachine/src/irSchema.conformance.test.ts create mode 100644 Trax.Api.StateMachine/tools/conformance/ir-schema.mjs create mode 100644 Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrSchemaTests.cs diff --git a/Trax.Api.StateMachine/AGENTS.md b/Trax.Api.StateMachine/AGENTS.md index af2a57030..ae60571f2 100644 --- a/Trax.Api.StateMachine/AGENTS.md +++ b/Trax.Api.StateMachine/AGENTS.md @@ -16,7 +16,7 @@ This file is the entry point. It routes; it does not restate the rules. | `src/rules/generateTypes.ts` | `generateContextTypes` and `generateMachineFactory`, which turn an IR into a twin. | | `tools/` | The node entrypoints the `trax machine` commands shell out to (`generate-twin.mjs`, `generate-corpus.mjs`), and `bundle-cli.mjs`, which bundles them with the engine compiled in for Trax.Cli's package. | | `machines/` | The shared fixtures both engines drive: each machine's IR, its generated twin and its differential corpus. | -| `conformance/` | Engine-neutral tables any engine replays: the canonical-wire vectors, the refusal-code registry, the strict-input cases (written from the CLR by the C# `StrictInputGoldenTests`). A vector goes here, not in one engine's tests. | +| `conformance/` | Engine-neutral tables any engine replays: the IR's JSON Schema (`ir.schema.json`, normative), the canonical-wire vectors, the refusal-code registry, the strict-input cases (written from the CLR by the C# `StrictInputGoldenTests`). A vector goes here, not in one engine's tests. | | `docs/design/` | The design notes the codegen was built from. Historical: the published docs are the rule. | ## The rules, and where they are written diff --git a/Trax.Api.StateMachine/conformance/ir.schema.json b/Trax.Api.StateMachine/conformance/ir.schema.json new file mode 100644 index 000000000..0a19a38b9 --- /dev/null +++ b/Trax.Api.StateMachine/conformance/ir.schema.json @@ -0,0 +1,970 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:trax:state-machine:ir-format:1", + "title": "Trax state machine IR, format 1", + "description": "The document IrExporter.Export writes and every engine reads. Normative: an engine reads every document this schema accepts, and an exporter writes nothing it refuses. Every object is closed, so a field the exporter adds without changing this schema fails the conformance tests. See traxsharp.net/docs/sdk-reference/statemachine-api/ir-format.", + "type": "object", + "required": [ + "id", + "version", + "initialState", + "initialContext", + "states", + "triggers", + "committedStates", + "context", + "inputs", + "transitions" + ], + "properties": { + "irFormat": { + "description": "The IR format a generated twin was generated for. The exporter does not write it; a twin generated with specifier imports adds it.", + "const": 1 + }, + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "initialState": { + "type": "string", + "minLength": 1 + }, + "initialContext": { + "type": "object" + }, + "states": { + "$ref": "#/$defs/names" + }, + "triggers": { + "$ref": "#/$defs/names" + }, + "committedStates": { + "$ref": "#/$defs/names" + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schema" + } + }, + "inputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schema" + } + }, + "invariants": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/rule" + } + }, + "transitions": { + "type": "array", + "items": { + "$ref": "#/$defs/transition" + } + }, + "outcomes": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/outcome" + } + }, + "timers": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/timer" + } + }, + "differential": { + "$ref": "#/$defs/differential" + } + }, + "additionalProperties": false, + "$defs": { + "names": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "type": { + "enum": [ + "string", + "number", + "integer", + "boolean", + "array", + "object" + ] + }, + "format": { + "enum": [ + "uuid", + "date-time", + "date-time-offset", + "date", + "time", + "duration", + "uri", + "char" + ] + }, + "schema": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/field" + } + } + }, + "additionalProperties": false + }, + "field": { + "type": "object", + "required": [ + "name", + "type", + "nullable", + "constraints" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/type" + }, + "nullable": { + "type": "boolean" + }, + "constraints": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumExact": { + "type": "array", + "items": { + "type": "string" + } + }, + "format": { + "$ref": "#/$defs/format" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/field" + } + }, + "values": { + "$ref": "#/$defs/valueShape" + }, + "keys": { + "$ref": "#/$defs/keyShape" + } + }, + "additionalProperties": false + }, + "valueShape": { + "description": "The schema each value of a dictionary, or each element of a list of non-records, is checked against.", + "type": "object", + "required": [ + "type", + "nullable" + ], + "properties": { + "type": { + "$ref": "#/$defs/type" + }, + "nullable": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumExact": { + "type": "array", + "items": { + "type": "string" + } + }, + "format": { + "$ref": "#/$defs/format" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/field" + } + }, + "values": { + "$ref": "#/$defs/valueShape" + }, + "keys": { + "$ref": "#/$defs/keyShape" + } + }, + "additionalProperties": false + }, + "keyShape": { + "description": "The schema each key of a dictionary not keyed by string is checked against.", + "type": "object", + "required": [ + "type", + "nullable" + ], + "properties": { + "type": { + "$ref": "#/$defs/type" + }, + "nullable": { + "const": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumExact": { + "type": "array", + "items": { + "type": "string" + } + }, + "format": { + "$ref": "#/$defs/format" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/field" + } + }, + "values": { + "$ref": "#/$defs/valueShape" + }, + "keys": { + "$ref": "#/$defs/keyShape" + } + }, + "additionalProperties": false + }, + "rule": { + "oneOf": [ + { + "type": "object", + "required": [ + "rule", + "source", + "field" + ], + "properties": { + "rule": { + "const": "present" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field" + ], + "properties": { + "rule": { + "const": "absent" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field" + ], + "properties": { + "rule": { + "const": "nonEmpty" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "type" + ], + "properties": { + "rule": { + "const": "ofType" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/type" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "type" + ], + "properties": { + "rule": { + "const": "arrayOf" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/type" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "values" + ], + "properties": { + "rule": { + "const": "oneOf" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "op", + "value" + ], + "properties": { + "rule": { + "const": "compare" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "op": { + "enum": [ + "gt", + "gte", + "lt", + "lte", + "eq" + ] + }, + "value": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "op", + "value" + ], + "properties": { + "rule": { + "const": "count" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "op": { + "enum": [ + "gt", + "gte", + "lt", + "lte", + "eq" + ] + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "op", + "value" + ], + "properties": { + "rule": { + "const": "length" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "op": { + "enum": [ + "gt", + "gte", + "lt", + "lte", + "eq" + ] + }, + "value": { + "type": "integer" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "source", + "field", + "value" + ], + "properties": { + "rule": { + "const": "boolEquals" + }, + "source": { + "enum": [ + "context", + "input" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "rules" + ], + "properties": { + "rule": { + "const": "all" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "rules" + ], + "properties": { + "rule": { + "const": "any" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "rule", + "name" + ], + "properties": { + "rule": { + "const": "custom" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + ] + }, + "reduction": { + "oneOf": [ + { + "type": "object", + "required": [ + "reduce" + ], + "properties": { + "reduce": { + "enum": [ + "keep", + "clear", + "reset" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reduce", + "name" + ], + "properties": { + "reduce": { + "const": "custom" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reduce", + "steps" + ], + "properties": { + "reduce": { + "const": "set" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "required": [ + "field", + "value" + ], + "properties": { + "field": { + "type": "string", + "minLength": 1 + }, + "value": { + "oneOf": [ + { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "const" + ], + "properties": { + "const": true + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + ] + }, + "transition": { + "type": "object", + "required": [ + "from", + "trigger", + "to" + ], + "properties": { + "from": { + "type": "string", + "minLength": 1 + }, + "trigger": { + "type": "string", + "minLength": 1 + }, + "to": { + "type": "string", + "minLength": 1 + }, + "guard": { + "$ref": "#/$defs/rule" + }, + "guardMessage": { + "type": "string" + }, + "reduce": { + "$ref": "#/$defs/reduction" + }, + "effect": { + "type": "object", + "required": [ + "keyPrefix" + ], + "properties": { + "keyPrefix": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "outcome": { + "type": "object", + "required": [ + "state", + "outcome", + "edges" + ], + "properties": { + "state": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "enum": [ + "done", + "failed", + "cancelled" + ] + }, + "edges": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "to" + ], + "properties": { + "to": { + "type": "string", + "minLength": 1 + }, + "guard": { + "$ref": "#/$defs/rule" + }, + "reduce": { + "$ref": "#/$defs/reduction" + } + }, + "additionalProperties": false + } + }, + "output": { + "$ref": "#/$defs/schema" + } + }, + "additionalProperties": false, + "if": { + "properties": { + "outcome": { + "const": "done" + } + } + }, + "then": { + "required": [ + "output" + ] + }, + "else": { + "not": { + "required": [ + "output" + ] + } + } + }, + "timer": { + "type": "object", + "required": [ + "state", + "trigger", + "target", + "rules" + ], + "properties": { + "state": { + "type": "string", + "minLength": 1 + }, + "trigger": { + "type": "string", + "pattern": "\\.after$" + }, + "target": { + "type": "string", + "minLength": 1 + }, + "rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "after": { + "type": "string", + "pattern": "^P" + }, + "field": { + "type": "object", + "required": [ + "name", + "min", + "max" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "min": { + "type": "string", + "pattern": "^P" + }, + "max": { + "type": "string", + "pattern": "^P" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "after" + ] + }, + { + "required": [ + "field" + ] + } + ] + }, + "differential": { + "description": "Test-only fuzzing inputs; stripped from a generated runtime machine.", + "type": "object", + "minProperties": 1, + "properties": { + "samples": { + "type": "object", + "additionalProperties": { + "type": "array" + } + }, + "seeds": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object" + }, + { + "type": "array", + "items": { + "type": "object" + } + } + ] + } + }, + "contexts": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/Trax.Api.StateMachine/package-lock.json b/Trax.Api.StateMachine/package-lock.json index 5c86ee8da..df133605f 100644 --- a/Trax.Api.StateMachine/package-lock.json +++ b/Trax.Api.StateMachine/package-lock.json @@ -13,6 +13,7 @@ "@types/node": "^24.19.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "ajv": "^8.18.0", "esbuild": "^0.28.2", "jsdom": "^25.0.1", "react": "^19.3.0", @@ -1615,6 +1616,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2091,6 +2109,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2377,6 +2419,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3028,6 +3077,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", diff --git a/Trax.Api.StateMachine/package.json b/Trax.Api.StateMachine/package.json index 0f9b3d063..7171d440b 100644 --- a/Trax.Api.StateMachine/package.json +++ b/Trax.Api.StateMachine/package.json @@ -67,6 +67,7 @@ "@types/node": "^24.19.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "ajv": "^8.18.0", "esbuild": "^0.28.2", "jsdom": "^25.0.1", "react": "^19.3.0", diff --git a/Trax.Api.StateMachine/src/irSchema.conformance.test.ts b/Trax.Api.StateMachine/src/irSchema.conformance.test.ts new file mode 100644 index 000000000..fecb3bf44 --- /dev/null +++ b/Trax.Api.StateMachine/src/irSchema.conformance.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +// @ts-expect-error: a plain node module with no types +import { irValidator } from "../tools/conformance/ir-schema.mjs"; +import { conformanceFile, irFile } from "./fixtures"; + +// conformance/ir.schema.json is the IR's normative shape. Every committed IR validates against it, and every +// object in it is closed, so an IR with a field the schema does not name is refused rather than half-read. + +const check = irValidator() as (ir: unknown) => string[]; +const machines = fs + .readdirSync(path.dirname(path.dirname(irFile("turnstile"))), { withFileTypes: true }) + .filter((d) => d.isDirectory() && fs.existsSync(irFile(d.name))) + .map((d) => d.name); +const committed: [string, string][] = [ + ...machines.map((m): [string, string] => [m, irFile(m)]), + ["strict-input", conformanceFile("strict-input", "strict.ir.json")], +]; +const read = (file: string) => JSON.parse(fs.readFileSync(file, "utf8")) as Record; + +describe("the IR schema", () => { + it("covers every committed machine", () => { + expect(machines).toEqual(expect.arrayContaining(["checkout", "gauge", "ingest", "reminder", "turnstile"])); + }); + + it.each(committed)("accepts the committed %s IR", (_, file) => { + expect(check(read(file))).toEqual([]); + }); + + it("accepts the irFormat a generated twin adds", () => { + expect(check({ irFormat: 1, ...read(irFile("turnstile")) })).toEqual([]); + }); + + it("refuses a top-level field it does not name", () => { + expect(check({ ...read(irFile("turnstile")), extra: true })).not.toEqual([]); + }); + + it("refuses a misspelt member of a rule", () => { + const ir = read(irFile("turnstile")) as { transitions: { guard?: Record }[] }; + const guarded = ir.transitions.find((t) => t.guard)!; + guarded.guard = { ...guarded.guard, feild: guarded.guard!.field }; + delete guarded.guard.field; + expect(check(ir)).not.toEqual([]); + }); + + it("refuses a rule kind it does not name", () => { + const ir = read(irFile("turnstile")) as { transitions: { guard?: unknown }[] }; + ir.transitions.find((t) => t.guard)!.guard = { rule: "matches", source: "input", field: "coin" }; + expect(check(ir)).not.toEqual([]); + }); + + it("refuses an output on an outcome other than done", () => { + const ir = read(irFile("ingest")) as { outcomes: Record> }; + ir.outcomes["Fetching.failed"]!.output = { fields: [] }; + expect(check(ir)).not.toEqual([]); + }); +}); diff --git a/Trax.Api.StateMachine/tools/conformance/ir-schema.mjs b/Trax.Api.StateMachine/tools/conformance/ir-schema.mjs new file mode 100644 index 000000000..9f1e78212 --- /dev/null +++ b/Trax.Api.StateMachine/tools/conformance/ir-schema.mjs @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const invokedDirectly = process.argv[1] === fileURLToPath(import.meta.url); + +// The exit code when the engine's dependencies are not installed, which the C# caller reads as "not installed" +// (the same code replay-sequences.mjs uses). +const MISSING_DEPENDENCIES = 3; + +let Ajv2020; +try { + ({ default: Ajv2020 } = await import("ajv/dist/2020.js")); +} catch (err) { + if (!invokedDirectly) throw err; + console.error("ajv is not installed: run npm ci in Trax.Api.StateMachine."); + process.exit(MISSING_DEPENDENCIES); +} + +const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "conformance", "ir.schema.json"); + +/** + * Compiles conformance/ir.schema.json once and returns a function that checks one IR document, answering the + * schema's errors as `instancePath message` lines (empty when the document is valid). + */ +export function irValidator() { + const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false }); + const validate = ajv.compile(JSON.parse(readFileSync(schemaPath, "utf8"))); + return (ir) => + validate(ir) ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`); +} + +// `node tools/conformance/ir-schema.mjs < irs.json` reads a JSON array of IR documents and writes, for each, the +// list of its errors, as one JSON array. The C# IrSchemaTests checks every IR its exporter writes this way. +if (invokedDirectly) { + const check = irValidator(); + const irs = JSON.parse(readFileSync(0, "utf8")); + process.stdout.write(JSON.stringify(irs.map(check))); +} diff --git a/Trax.Docs/sdk-reference/statemachine-api/ir-format.md b/Trax.Docs/sdk-reference/statemachine-api/ir-format.md index 7526b11f0..da8aea258 100644 --- a/Trax.Docs/sdk-reference/statemachine-api/ir-format.md +++ b/Trax.Docs/sdk-reference/statemachine-api/ir-format.md @@ -125,6 +125,14 @@ schema hash. A twin generated with specifier imports records it as `irFormat` in `@traxsharp/state-machine` refuses, when the twin is imported, a format newer than it reads or older than it still reads, saying whether to upgrade the package or regenerate. An IR without `irFormat` is not checked. +## The schema + +The format is also written as a JSON Schema (draft 2020-12), +[`conformance/ir.schema.json`](https://github.com/TraxSharp/Trax/blob/main/Trax.Api.StateMachine/conformance/ir.schema.json), +and the schema is normative: an engine reads every document it accepts, and the exporter writes nothing it refuses. +Every object in it is closed, so a field it does not name is refused rather than ignored. Both runtimes' tests check +every IR the exporter writes against it, so a field added to the IR without the schema fails them. + ## Transitions Each transition carries its structure plus its guard and reducer as data: diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrSchemaTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrSchemaTests.cs new file mode 100644 index 000000000..0f54f7f08 --- /dev/null +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/IrSchemaTests.cs @@ -0,0 +1,95 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Trax.Effect.StateMachine.Tests.Helpers; + +namespace Trax.Effect.StateMachine.Tests.Conformance; + +/// +/// conformance/ir.schema.json is the IR's normative shape, which an engine in any language is written against. +/// Every IR this exporter writes for the machines in this test assembly must validate, and the schema's objects are +/// closed, so a field the exporter starts writing fails here until the schema, and the IR format page, name it. +/// +public class IrSchemaTests +{ + [Test] + public void Every_IR_the_exporter_writes_for_the_test_machines_validates() + { + var irs = ExportedIrs(); + irs.Should().NotBeEmpty(); + + var results = TwinEngine.ValidateIrs(irs.Select(i => i.Ir).ToList()); + + var invalid = irs.Zip(results) + .Where(r => r.Second.Count > 0) + .Select(r => $"{r.First.Name}:\n " + string.Join("\n ", r.Second)) + .ToList(); + invalid + .Should() + .BeEmpty( + "every exported IR matches conformance/ir.schema.json:\n" + + string.Join("\n", invalid) + ); + } + + [Test] + public void The_test_machines_reach_every_optional_section() + { + var irs = ExportedIrs().Select(i => i.Ir).ToList(); + + foreach (var section in new[] { "invariants", "outcomes", "timers", "differential" }) + irs.Should().Contain(ir => ir.ContainsKey(section), $"some test machine has {section}"); + irs.Should() + .Contain( + ir => ir["transitions"]!.AsArray().Any(t => t!["effect"] != null), + "some test machine has an effect-bound edge" + ); + } + + private static List<(string Name, JsonObject Ir)> ExportedIrs() + { + var export = typeof(IrExporter).GetMethod(nameof(IrExporter.Export))!; + var refuses = typeof(IrExporter).GetMethod( + nameof(IrExporter.RefusesByDesign), + BindingFlags.NonPublic | BindingFlags.Static + )!; + const BindingFlags statics = + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Static + | BindingFlags.DeclaredOnly; + + var irs = new List<(string, JsonObject)>(); + foreach ( + var type in typeof(IrSchemaTests) + .Assembly.GetTypes() + .Where(t => !t.ContainsGenericParameters) + .OrderBy(t => t.FullName, StringComparer.Ordinal) + ) + foreach ( + var (name, read) in type.GetFields(statics) + .Where(f => IsBuiltMachine(f.FieldType)) + .Select(f => (f.Name, (Func)(() => f.GetValue(null)))) + .Concat( + type.GetProperties(statics) + .Where(p => + IsBuiltMachine(p.PropertyType) && p.GetIndexParameters().Length == 0 + ) + .Select(p => (p.Name, (Func)(() => p.GetValue(null)))) + ) + ) + { + if (read() is not { } machine) + continue; + var args = machine.GetType().GetGenericArguments(); + if ((bool)refuses.MakeGenericMethod(args).Invoke(null, [machine])!) + continue; + var ir = (string)export.MakeGenericMethod(args).Invoke(null, [machine])!; + irs.Add(($"{type.FullName}.{name}", (JsonObject)JsonNode.Parse(ir)!)); + } + return irs; + } + + private static bool IsBuiltMachine(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(BuiltMachine<,>); +} diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs index 5f7aae105..e838854b7 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs @@ -77,7 +77,8 @@ IReadOnlyList sequences ), }; - var answers = (JsonArray)JsonNode.Parse(Run(engineRoot, request.ToJsonString()))!; + var answers = (JsonArray) + JsonNode.Parse(Run(engineRoot, "tools/replay-sequences.mjs", request.ToJsonString()))!; return answers .Select(sequence => (IReadOnlyList) @@ -92,6 +93,26 @@ IReadOnlyList sequences .ToList(); } + /// + /// Checks each of against conformance/ir.schema.json with the engine's validator + /// (tools/conformance/ir-schema.mjs), and returns each one's errors, empty when it is valid. + /// + public static IReadOnlyList> ValidateIrs(IReadOnlyList irs) + { + var engineRoot = EngineRoot(); + var request = new JsonArray(irs.Select(ir => (JsonNode)ir.DeepClone()).ToArray()); + var answers = (JsonArray) + JsonNode.Parse( + Run(engineRoot, "tools/conformance/ir-schema.mjs", request.ToJsonString()) + )!; + return answers + .Select(errors => + (IReadOnlyList) + ((JsonArray)errors!).Select(e => e!.GetValue()).ToList() + ) + .ToList(); + } + private static string EngineRoot() { if (!NodeIsAvailable()) @@ -110,9 +131,9 @@ private static void Unavailable(string why) Assert.Ignore($"The TypeScript twin cannot run: {why} Skipping the cross-engine check."); } - private static string Run(string engineRoot, string request) + private static string Run(string engineRoot, string script, string request) { - var start = new ProcessStartInfo("node", "tools/replay-sequences.mjs") + var start = new ProcessStartInfo("node", script) { WorkingDirectory = engineRoot, RedirectStandardInput = true, @@ -130,14 +151,12 @@ private static string Run(string engineRoot, string request) if (!process.WaitForExit(NodeTimeout)) { process.Kill(entireProcessTree: true); - Assert.Fail($"replay-sequences.mjs did not answer within {NodeTimeout}."); + Assert.Fail($"{script} did not answer within {NodeTimeout}."); } if (process.ExitCode == MissingDependencies) Unavailable("its dependencies are not installed (npm ci in Trax.Api.StateMachine)."); - process - .ExitCode.Should() - .Be(0, $"replay-sequences.mjs failed: {stderr.GetAwaiter().GetResult()}"); + process.ExitCode.Should().Be(0, $"{script} failed: {stderr.GetAwaiter().GetResult()}"); return stdout.GetAwaiter().GetResult(); } From 71341cbbd39885d31664b0135b22d1d40208e9df Mon Sep 17 00:00:00 2001 From: Theauxm <43664045+Theauxm@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:06:57 -0600 Subject: [PATCH 4/5] test(machines): run every engine through one conformance runner Each engine read the shared fixtures its own way, inside its own test framework, so a third engine would need a reader for every fixture shape as well as an engine. tools/conformance/run.mjs turns every case in machines/ and conformance/ into requests and sends them to one engine, the testee, over a line-delimited JSON protocol written down in conformance/PROTOCOL.md (load, advance, outcome, timer, rehydrate, serialize, canonical). It compares only codes and wire bytes. The TypeScript testee builds each machine from its IR through the package's exports. The C# testee serves its test machines by name and answers from inside NUnit, with the runner started in --stdio mode, so no separate executable is needed. Its load step checks that the committed IR is the one the C# machine exports, apart from the test-only differential block. Both pass all 417 cases, and a testee that gives one case the wrong code fails with that case named. The strict-input golden now records the wire after a transition, as the protocol answers it. --- Trax.Api.StateMachine/AGENTS.md | 3 +- Trax.Api.StateMachine/conformance/PROTOCOL.md | 91 +++++++++ .../conformance/strict-input/cases.json | 98 +++++----- .../src/strictInput.conformance.test.ts | 4 +- .../tools/conformance/kit.mjs | 134 +++++++++++++ .../tools/conformance/run.mjs | 92 +++++++++ .../tools/conformance/run.test.mjs | 67 +++++++ .../tools/conformance/testee-ts.mjs | 96 +++++++++ .../Conformance/ConformanceRunnerTests.cs | 23 +++ .../Conformance/StrictInputGoldenTests.cs | 6 +- .../Helpers/ConformanceTestee.cs | 185 ++++++++++++++++++ .../Helpers/TwinEngine.cs | 36 ++++ 12 files changed, 782 insertions(+), 53 deletions(-) create mode 100644 Trax.Api.StateMachine/conformance/PROTOCOL.md create mode 100644 Trax.Api.StateMachine/tools/conformance/kit.mjs create mode 100644 Trax.Api.StateMachine/tools/conformance/run.mjs create mode 100644 Trax.Api.StateMachine/tools/conformance/run.test.mjs create mode 100644 Trax.Api.StateMachine/tools/conformance/testee-ts.mjs create mode 100644 Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceRunnerTests.cs create mode 100644 Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/ConformanceTestee.cs diff --git a/Trax.Api.StateMachine/AGENTS.md b/Trax.Api.StateMachine/AGENTS.md index ae60571f2..40841b4e3 100644 --- a/Trax.Api.StateMachine/AGENTS.md +++ b/Trax.Api.StateMachine/AGENTS.md @@ -15,6 +15,7 @@ This file is the entry point. It routes; it does not restate the rules. | `src/server/`, `src/react.ts` | The server-authoritative controller, and `@traxsharp/state-machine/react` (`useServerMachine`, `useRememberedMachine`). | | `src/rules/generateTypes.ts` | `generateContextTypes` and `generateMachineFactory`, which turn an IR into a twin. | | `tools/` | The node entrypoints the `trax machine` commands shell out to (`generate-twin.mjs`, `generate-corpus.mjs`), and `bundle-cli.mjs`, which bundles them with the engine compiled in for Trax.Cli's package. | +| `tools/conformance/` | The conformance runner (`run.mjs`), which sends every case of `machines/` and `conformance/` to one engine over the testee protocol in `conformance/PROTOCOL.md`, the TypeScript testee, and the IR schema validator. | | `machines/` | The shared fixtures both engines drive: each machine's IR, its generated twin and its differential corpus. | | `conformance/` | Engine-neutral tables any engine replays: the IR's JSON Schema (`ir.schema.json`, normative), the canonical-wire vectors, the refusal-code registry, the strict-input cases (written from the CLR by the C# `StrictInputGoldenTests`). A vector goes here, not in one engine's tests. | | `docs/design/` | The design notes the codegen was built from. Historical: the published docs are the rule. | @@ -26,7 +27,7 @@ This file is the entry point. It routes; it does not restate the rules. here, so the twin defers to the server on it. See [the codegen pipeline](https://traxsharp.net/docs/statemachine/codegen-pipeline) and [the IR format](https://traxsharp.net/docs/sdk-reference/statemachine-api/ir-format). - **The two engines agree case for case.** The differential corpus under `machines//` is enumerated here - and replayed by the C# engine; only result codes are contract, messages may differ. The wire is canonical JSON + and replayed by the C# engine, and the conformance runner sends the whole kit to each engine, the C# one included; only result codes are contract, messages may differ. The wire is canonical JSON (RFC 8785) in both ([canonical wire](https://traxsharp.net/docs/statemachine/canonical-wire)). - **Every operation is total.** The engine and the clients answer a refusal or a transport failure as a value (`problem`, `TRANSPORT_ERROR`), never by throwing. diff --git a/Trax.Api.StateMachine/conformance/PROTOCOL.md b/Trax.Api.StateMachine/conformance/PROTOCOL.md new file mode 100644 index 000000000..4b005311c --- /dev/null +++ b/Trax.Api.StateMachine/conformance/PROTOCOL.md @@ -0,0 +1,91 @@ +# The conformance runner and its testee protocol + +`tools/conformance/run.mjs` turns every case in `machines/` and `conformance/` into requests, sends them to one +engine, the **testee**, and compares what it answers with what the case expects. An engine in any language joins by +implementing the testee side of this protocol over its public API, not over its internals, so a bug in how a binding +marshals a value is caught too. Only codes and wire bytes are compared; messages are never sent. + +## Running it + +```bash +node tools/conformance/run.mjs --testee node tools/conformance/testee-ts.mjs # spawn a testee command +node tools/conformance/run.mjs --stdio # be spawned by one +``` + +With `--testee`, the runner starts the command and talks to it over the command's stdin and stdout. With `--stdio`, +the roles on the pipes are reversed: the runner writes requests on its own stdout and reads answers on its stdin, so a +test in another language (the C# `ConformanceRunnerTests`) can start the runner and answer from inside its own +process. Either way the runner writes its report on stderr and exits 0 when every case passed, 1 when any failed. + +`--only ` runs the cases whose name starts with the prefix. + +## The protocol + +One JSON object per line, UTF-8, in both directions. The runner sends a request and waits for its answer before +sending the next, so a testee may answer synchronously. Every request has an `id` and an `op`; the answer carries the +same `id`. A testee answers an `op` it does not implement with `{ "id", "unsupported": true }`, which fails that case. + +**`load`** gives the testee a machine to serve under `machine`, as its IR. A testee that builds machines from an IR +builds it; one that only has machines compiled in checks that the IR is the one its machine exports, and answers +`error` when it is not. + +```json +{ "id": 1, "op": "load", "machine": "turnstile", "ir": { ... } } +{ "id": 1, "loaded": true } +{ "id": 1, "error": "the turnstile this engine has exports a different IR" } +``` + +**`advance`**, **`outcome`** and **`timer`** apply a trigger to a snapshot through the entry point that owns it: a +client's trigger through advance, an invoked train's outcome (`Fetching.done`) through the outcome path, a state's +timer (`Review.after`) through the timer path. `snapshot` is an object (`machine`, `version`, `state`, `context`); +`input` is absent or `null` when the trigger takes none. The answer is the canonical wire after the step, or the +refusal code. + +```json +{ "id": 2, "op": "advance", "machine": "turnstile", "snapshot": { ... }, "trigger": "Coin", "input": { "coin": "quarter" } } +{ "id": 2, "outcome": "transitioned", "wire": "{\"machine\":\"turnstile\",...}" } +{ "id": 2, "outcome": "rejected", "reason": "GUARD_FAILED" } +``` + +**`rehydrate`** reads stored text as a snapshot of `machine`. The answer is the snapshot's canonical wire, or the +refusal code. + +```json +{ "id": 3, "op": "rehydrate", "machine": "turnstile", "text": "{\"machine\":\"turnstile\",...}" } +{ "id": 3, "result": "ok", "wire": "..." } +{ "id": 3, "result": "error", "code": "MALFORMED" } +``` + +**`serialize`** writes a snapshot object of `machine` as canonical wire, without validating it. + +```json +{ "id": 4, "op": "serialize", "machine": "checkout", "snapshot": { ... } } +{ "id": 4, "wire": "..." } +``` + +**`canonical`** writes the snapshot `{ "machine": "m", "version": 1, "state": "S", "context": }` as +canonical wire, for the vectors in `canonical-wire.json`. The context is given as JSON text in `context`, or, for a +string no JSON parser carries (a lone surrogate), as UTF-16 code units: `stringUnits` is the context +`{ "s": }`, `keyUnits` the context `{ : 1 }`. + +```json +{ "id": 5, "op": "canonical", "context": "{\"n\":1e21}" } +{ "id": 5, "wire": "{\"machine\":\"m\",\"version\":1,\"state\":\"S\",\"context\":{\"n\":1e+21}}" } +``` + +**`end`** is the last request. The testee answers `{ "id" }` and exits. + +## What the runner sends + +| Source | Requests | +| --- | --- | +| `machines//.ir.json` | `load` | +| `machines//differential.json` | one `advance`, `outcome` or `timer` per case, by the trigger's kind in the IR | +| `machines//fixtures/advance/*.json` | `advance` | +| `machines//fixtures/rehydrate/*.json` | `rehydrate` | +| `machines//wire-handoff.json` | `serialize` | +| `conformance/strict-input/` | `load`, then one `advance` per case | +| `conformance/canonical-wire.json` | `canonical` | + +`machines/turnstile/migration.json` is not sent: it migrates from a v1 to a v2 machine that has no IR, so each engine +replays it in its own tests. diff --git a/Trax.Api.StateMachine/conformance/strict-input/cases.json b/Trax.Api.StateMachine/conformance/strict-input/cases.json index 55dce5a6c..a843f6037 100644 --- a/Trax.Api.StateMachine/conformance/strict-input/cases.json +++ b/Trax.Api.StateMachine/conformance/strict-input/cases.json @@ -5,11 +5,11 @@ "trigger": "Submit", "validInput": {"amount":12.5,"at":"2026-10-12T10:00:00","atOffset":"2026-10-12T10:00:00.1234567+02:00","byColor":{"red":1},"byId":{"6f9619ff-8b86-d011-b42d-00c04fc964ff":1},"byNumber":{"1":"a"},"clock":"23:59:59.5","color":"darkGreen","day":"2024-02-29","huge":1,"hugeUnsigned":1,"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","initial":"x","link":"https://example.com/a?b=c","maybeColor":null,"owners":{"a":"00000000-0000-0000-0000-000000000000"},"palette":["red","sky-blue"],"patience":"PT5M","price":3,"qty":2,"ratio":0.25,"small":1.5,"wide":1e+300}, "cases": [ - {"expect":{"outcome":"transitioned"},"name":"valid input"}, - {"expect":{"outcome":"transitioned"},"field":"color","name":"enum name","value":"red"}, - {"expect":{"outcome":"transitioned"},"field":"color","name":"enum camelCase name","value":"darkGreen"}, - {"expect":{"outcome":"transitioned"},"field":"color","name":"enum PascalCase name","value":"DarkGreen"}, - {"expect":{"outcome":"transitioned"},"field":"color","name":"enum member name","value":"sky-blue"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"name":"valid input"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"color","name":"enum name","value":"red"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"color","name":"enum camelCase name","value":"darkGreen"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"color","name":"enum PascalCase name","value":"DarkGreen"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"color","name":"enum member name","value":"sky-blue"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum C# name of a renamed member","value":"skyBlue"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum renamed member in upper case","value":"SKY-BLUE"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum renamed member in title case","value":"Sky-Blue"}, @@ -18,9 +18,9 @@ {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum number as text","value":"1"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"color","name":"enum empty","value":""}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"maybeColor","name":"nullable enum unknown","value":"purple"}, - {"expect":{"outcome":"transitioned"},"field":"maybeColor","name":"nullable enum name","value":"red"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"maybeColor","name":"nullable enum name","value":"red"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"palette","name":"list of enums with an unknown one","value":["red","purple"]}, - {"expect":{"outcome":"transitioned"},"field":"id","name":"uuid upper case","value":"6F9619FF-8B86-D011-B42D-00C04FC964FF"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"id","name":"uuid upper case","value":"6F9619FF-8B86-D011-B42D-00C04FC964FF"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid without hyphens","value":"6f9619ff8b86d011b42d00c04fc964ff"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid in braces","value":"{6f9619ff-8b86-d011-b42d-00c04fc964ff}"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"id","name":"uuid not hex","value":"6f9619ff-8b86-d011-b42d-00c04fc964fg"}, @@ -29,24 +29,24 @@ {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"owners","name":"dictionary of uuids with a bad one","value":{"a":"nope"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"total","name":"computed property sent","value":1}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"total","name":"computed property sent as what it computes","value":6}, - {"expect":{"outcome":"transitioned"},"field":"byNumber","name":"int key","value":{"-5":"a"}}, - {"expect":{"outcome":"transitioned"},"field":"byNumber","name":"int key at int max","value":{"2147483647":"a"}}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"byNumber","name":"int key","value":{"-5":"a"}}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"byNumber","name":"int key at int max","value":{"2147483647":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key past int max","value":{"2147483648":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key not a number","value":{"x":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a fraction","value":{"1.0":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with an exponent","value":{"1e3":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a leading zero","value":{"01":"a"}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byNumber","name":"int key with a space","value":{" 1":"a"}}, - {"expect":{"outcome":"transitioned"},"field":"byColor","name":"enum key","value":{"DarkGreen":1}}, - {"expect":{"outcome":"transitioned"},"field":"byColor","name":"enum key renamed","value":{"sky-blue":1}}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"byColor","name":"enum key","value":{"DarkGreen":1}}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"byColor","name":"enum key renamed","value":{"sky-blue":1}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byColor","name":"enum key renamed in upper case","value":{"SKY-BLUE":1}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byColor","name":"enum key unknown","value":{"purple":1}}, - {"expect":{"outcome":"transitioned"},"field":"byId","name":"guid key","value":{"6F9619FF-8B86-D011-B42D-00C04FC964FF":1}}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"byId","name":"guid key","value":{"6F9619FF-8B86-D011-B42D-00C04FC964FF":1}}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"byId","name":"guid key without hyphens","value":{"6f9619ff8b86d011b42d00c04fc964ff":1}}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time Z","value":"2026-10-12T10:00:00Z"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time fraction","value":"2026-10-12T10:00:00.123Z"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time seven fraction digits and an offset","value":"2026-10-12T10:00:00.1234567+02:00"}, - {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time offset","value":"2026-10-12T10:00:00-14:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time Z","value":"2026-10-12T10:00:00Z"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time fraction","value":"2026-10-12T10:00:00.123Z"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time seven fraction digits and an offset","value":"2026-10-12T10:00:00.1234567+02:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"atOffset","name":"date-time offset","value":"2026-10-12T10:00:00-14:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time offset too far","value":"2026-10-12T10:00:00+14:30"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time without seconds","value":"2026-10-12T10:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time with a space","value":"2026-10-12 10:00:00"}, @@ -57,88 +57,88 @@ {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time year zero","value":"0000-01-01T00:00:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time lower-case t","value":"2026-10-12t10:00:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time full-width digit","value":"2026-10-12T10:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time the least DateTime","value":"0001-01-01T00:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time the greatest DateTime","value":"9999-12-31T23:59:59.9999999"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time the least DateTime","value":"0001-01-01T00:00:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time the greatest DateTime","value":"9999-12-31T23:59:59.9999999"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time offset before year 1 in UTC","value":"0001-01-01T00:59:59+01:00"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time offset in year 1 in UTC","value":"0001-01-01T01:00:00+01:00"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time negative offset in year 1","value":"0001-01-01T00:00:00-01:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time offset in year 1 in UTC","value":"0001-01-01T01:00:00+01:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time negative offset in year 1","value":"0001-01-01T00:00:00-01:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"at","name":"date-time offset past year 9999 in UTC","value":"9999-12-31T23:59:59.9999999-01:00"}, - {"expect":{"outcome":"transitioned"},"field":"at","name":"date-time offset in year 9999 in UTC","value":"9999-12-31T23:59:59.9999999+01:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"at","name":"date-time offset in year 9999 in UTC","value":"9999-12-31T23:59:59.9999999+01:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset before year 1 in UTC","value":"0001-01-01T00:00:00+01:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset past year 9999 in UTC","value":"9999-12-31T23:30:00-00:31"}, - {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset the least, in UTC","value":"0001-01-01T00:00:00Z"}, - {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset the least, with its offset","value":"0001-01-01T00:00:00+00:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"atOffset","name":"date-time-offset the least, in UTC","value":"0001-01-01T00:00:00Z"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"atOffset","name":"date-time-offset the least, with its offset","value":"0001-01-01T00:00:00+00:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset with no offset on the first day","value":"0001-01-01T23:00:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"atOffset","name":"date-time-offset with no offset on the last day","value":"9999-12-31T00:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset with no offset on the second day","value":"0001-01-02T00:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"atOffset","name":"date-time-offset with no offset on the next-to-last day","value":"9999-12-30T23:59:59"}, - {"expect":{"outcome":"transitioned"},"field":"day","name":"date leap day","value":"2024-02-29"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"atOffset","name":"date-time-offset with no offset on the second day","value":"0001-01-02T00:00:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"atOffset","name":"date-time-offset with no offset on the next-to-last day","value":"9999-12-30T23:59:59"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"day","name":"date leap day","value":"2024-02-29"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date leap day of a common year","value":"2026-02-29"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date leap day of a century","value":"1900-02-29"}, - {"expect":{"outcome":"transitioned"},"field":"day","name":"date leap day of a 400th year","value":"2000-02-29"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"day","name":"date leap day of a 400th year","value":"2000-02-29"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date year zero","value":"0000-01-01"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date month 13","value":"2026-13-01"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"day","name":"date with a time","value":"2026-10-12T00:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"clock","name":"time midnight","value":"00:00:00"}, - {"expect":{"outcome":"transitioned"},"field":"clock","name":"time fraction","value":"12:30:15.1234567"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"clock","name":"time midnight","value":"00:00:00"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"clock","name":"time fraction","value":"12:30:15.1234567"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"clock","name":"time without seconds","value":"12:30"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"clock","name":"time minute 60","value":"12:60:00"}, - {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration","value":"P1DT2H30M"}, - {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration fraction","value":"PT0.5S"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"patience","name":"duration","value":"P1DT2H30M"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"patience","name":"duration fraction","value":"PT0.5S"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration .NET form","value":"00:05:00"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration weeks","value":"P1W"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration negative","value":"-PT5M"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration Arabic-Indic digit","value":"PT٥M"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration past TimeSpan","value":"P999999999D"}, - {"expect":{"outcome":"transitioned"},"field":"patience","name":"duration TimeSpan.MaxValue","value":"P10675199DT2H48M5.4775807S"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"patience","name":"duration TimeSpan.MaxValue","value":"P10675199DT2H48M5.4775807S"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"patience","name":"duration one tick past TimeSpan.MaxValue","value":"P10675199DT2H48M5.4775808S"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri","value":"urn:isbn:0451450523"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri","value":"urn:isbn:0451450523"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri relative","value":"/a/b"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri with a space","value":"https://example.com/a b"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri unknown scheme only","value":"ab:"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri unknown scheme only","value":"ab:"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri known scheme with no authority","value":"https:"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri one-letter scheme","value":"c:foo"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri unclosed IPv6 host","value":"http://[::1"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri IPv6 host and port","value":"http://[::1]:8080/a"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri IPv6 host and port","value":"http://[::1]:8080/a"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri IPv6 host with two double colons","value":"http://[1::2::3]/"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri IPv4-mapped IPv6 host","value":"http://[::ffff:1.2.3.4]/"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri IPv4-mapped IPv6 host","value":"http://[::ffff:1.2.3.4]/"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri dotted host with an octet past 255","value":"http://1.2.3.999/"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri port past 65535","value":"http://a:99999/"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri port 65535","value":"http://a:65535/a?b=c#d"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri port 65535","value":"http://a:65535/a?b=c#d"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri empty host","value":"http:///a"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri host with an empty label","value":"http://a..b/"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri with a user","value":"http://u@a/"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri file","value":"file:///tmp/a"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri file","value":"file:///tmp/a"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri file with a UNC path","value":"file:////share/a"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri file with a drive","value":"file:///a:x"}, - {"expect":{"outcome":"transitioned"},"field":"link","name":"uri mailto","value":"mailto:a.b@example.com"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"link","name":"uri mailto","value":"mailto:a.b@example.com"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri mailto with no address","value":"mailto:a"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri net.pipe with a port","value":"net.pipe://a:1/"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri malformed escape","value":"http://a/%zz"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"link","name":"uri non-ASCII","value":"http://a/é"}, - {"expect":{"outcome":"transitioned"},"field":"initial","name":"char","value":"é"}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"initial","name":"char","value":"é"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char empty","value":""}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char two letters","value":"ab"}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"initial","name":"char astral","value":"😀"}, - {"expect":{"outcome":"transitioned"},"field":"amount","name":"decimal max","value":7.9e+28}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"amount","name":"decimal max","value":7.9e+28}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"amount","name":"decimal past max","value":1e+30}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"amount","name":"decimal past min","value":-1e+30}, - {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float max","value":3.4e+38}, - {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float max as System.Text.Json writes it","value":3.4028235e+38}, - {"expect":{"outcome":"transitioned"},"field":"ratio","name":"float min as System.Text.Json writes it","value":-3.4028235e+38}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"ratio","name":"float max","value":3.4e+38}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"ratio","name":"float max as System.Text.Json writes it","value":3.4028235e+38}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"ratio","name":"float min as System.Text.Json writes it","value":-3.4028235e+38}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"ratio","name":"float just past its written max","value":3.4028236e+38}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"ratio","name":"float past max","value":3.5e+38}, - {"expect":{"outcome":"transitioned"},"field":"small","name":"half max","value":65504}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"small","name":"half max","value":65504}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"small","name":"half past max","value":65505}, - {"expect":{"outcome":"transitioned"},"field":"wide","name":"double has no extra range","value":-1.7e+308}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"wide","name":"double has no extra range","value":-1.7e+308}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 written with an exponent","value":1e+38}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 at 1e21","value":1e+21}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 below -1e21","value":-1e+21}, - {"expect":{"outcome":"transitioned"},"field":"huge","name":"int128 just under 1e21","value":999999999999999900000}, - {"expect":{"outcome":"transitioned"},"field":"huge","name":"int128 just over -1e21","value":-999999999999999900000}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"huge","name":"int128 just under 1e21","value":999999999999999900000}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"huge","name":"int128 just over -1e21","value":-999999999999999900000}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"huge","name":"int128 fraction","value":1.5}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"hugeUnsigned","name":"uint128 at 1e21","value":1e+21}, - {"expect":{"outcome":"transitioned"},"field":"hugeUnsigned","name":"uint128 just under 1e21","value":999999999999999900000}, + {"expect":{"outcome":"transitioned","wire":"{\"machine\":\"strict\",\"version\":1,\"state\":\"Done\",\"context\":{}}"},"field":"hugeUnsigned","name":"uint128 just under 1e21","value":999999999999999900000}, {"expect":{"outcome":"rejected","reason":"INVALID_INPUT"},"field":"hugeUnsigned","name":"uint128 negative","value":-1} ] } diff --git a/Trax.Api.StateMachine/src/strictInput.conformance.test.ts b/Trax.Api.StateMachine/src/strictInput.conformance.test.ts index 8f7e62498..abd75a43d 100644 --- a/Trax.Api.StateMachine/src/strictInput.conformance.test.ts +++ b/Trax.Api.StateMachine/src/strictInput.conformance.test.ts @@ -12,7 +12,7 @@ type Case = { name: string; field?: string; value?: unknown; - expect: { outcome: "transitioned" } | { outcome: "rejected"; reason: string }; + expect: { outcome: "transitioned"; wire: string } | { outcome: "rejected"; reason: string }; }; const golden = JSON.parse( @@ -38,7 +38,7 @@ describe("strict input conformance", () => { expect( result.outcome === "transitioned" - ? { outcome: "transitioned" } + ? { outcome: "transitioned", wire: machine.serialize(result.snapshot) } : { outcome: "rejected", reason: result.reason }, ).toEqual(c.expect); }); diff --git a/Trax.Api.StateMachine/tools/conformance/kit.mjs b/Trax.Api.StateMachine/tools/conformance/kit.mjs new file mode 100644 index 000000000..5dd139366 --- /dev/null +++ b/Trax.Api.StateMachine/tools/conformance/kit.mjs @@ -0,0 +1,134 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// The conformance kit as requests: every case in machines/ and conformance/, each a request for the testee and the +// answer it must give (conformance/PROTOCOL.md). Engine-independent: nothing here imports the engine. + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const machinesDir = join(root, "machines"); +const conformanceDir = join(root, "conformance"); + +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +/** RFC 8785: keys sorted by UTF-16 code unit, values as JSON.stringify writes them. */ +export function canonical(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`) + .join(",")}}`; +} + +/** The canonical wire of a snapshot object: the envelope in fixed order, the context canonical. */ +export function wireOf(snapshot) { + return ( + `{"machine":${JSON.stringify(snapshot.machine)},"version":${JSON.stringify(snapshot.version)},` + + `"state":${JSON.stringify(snapshot.state)},"context":${canonical(snapshot.context)}}` + ); +} + +// The entry point a trigger goes through, by its kind in the IR. +function opFor(ir, trigger) { + if (ir.outcomes && Object.hasOwn(ir.outcomes, trigger)) return "outcome"; + if ((ir.timers ?? []).some((t) => t.trigger === trigger)) return "timer"; + return "advance"; +} + +function step(name, machine, op, given, when, expect) { + const request = { op, machine, snapshot: given, trigger: when.trigger }; + if (op !== "timer" && when.input !== undefined) request.input = when.input; + return { + name, + request, + expect: + expect.outcome === "transitioned" + ? { outcome: "transitioned", wire: expect.wire ?? wireOf(expect.snapshot) } + : { outcome: "rejected", reason: expect.reason }, + }; +} + +function jsonFiles(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => [f.replace(/\.json$/, ""), readJson(join(dir, f))]); +} + +function machineCases(machine) { + const dir = join(machinesDir, machine); + const ir = readJson(join(dir, `${machine}.ir.json`)); + const cases = [{ name: `${machine}/load`, request: { op: "load", machine, ir }, expect: { loaded: true } }]; + + const differential = join(dir, "differential.json"); + if (existsSync(differential)) + readJson(differential).cases.forEach((c, i) => { + const op = opFor(ir, c.when.trigger); + cases.push( + step(`${machine}/differential/${i} ${c.given.state} + ${c.when.trigger}`, machine, op, c.given, c.when, c.expect), + ); + }); + + for (const [file, f] of jsonFiles(join(dir, "fixtures", "advance"))) + cases.push(step(`${machine}/advance/${file}`, machine, opFor(ir, f.when.trigger), f.given, f.when, f.expect)); + + for (const [file, f] of jsonFiles(join(dir, "fixtures", "rehydrate"))) + cases.push({ + name: `${machine}/rehydrate/${file}`, + request: { op: "rehydrate", machine, text: f.raw ?? JSON.stringify(f.json) }, + expect: + f.expect.result === "ok" + ? { result: "ok", wire: wireOf(f.expect.snapshot) } + : { result: "error", code: f.expect.code }, + }); + + const handoff = join(dir, "wire-handoff.json"); + if (existsSync(handoff)) + for (const s of readJson(handoff).samples) + cases.push({ + name: `${machine}/wire-handoff/${s.name}`, + request: { op: "serialize", machine, snapshot: s.build }, + expect: { wire: s.snapshot }, + }); + + return cases; +} + +function strictInputCases() { + const dir = join(conformanceDir, "strict-input"); + const golden = readJson(join(dir, "cases.json")); + const machine = golden.machine; + const given = JSON.parse(golden.start); + return [ + { name: "strict-input/load", request: { op: "load", machine, ir: readJson(join(dir, "strict.ir.json")) }, expect: { loaded: true } }, + ...golden.cases.map((c) => { + const input = structuredClone(golden.validInput); + if (c.field !== undefined) input[c.field] = c.value; + return { + name: `strict-input/${c.name}`, + request: { op: "advance", machine, snapshot: given, trigger: golden.trigger, input }, + expect: c.expect, + }; + }), + ]; +} + +function canonicalWireCases() { + const doc = readJson(join(conformanceDir, "canonical-wire.json")); + return doc.cases.map(({ name, wire, ...context }) => ({ + name: `canonical-wire/${name}`, + request: { op: "canonical", ...context }, + expect: { wire: `{"machine":"m","version":1,"state":"S","context":${wire}}` }, + })); +} + +/** Every case of the kit, in a fixed order: each machine's, then the strict inputs, then the wire vectors. */ +export function kitCases() { + const machines = readdirSync(machinesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && existsSync(join(machinesDir, d.name, `${d.name}.ir.json`))) + .map((d) => d.name) + .sort(); + return [...machines.flatMap(machineCases), ...strictInputCases(), ...canonicalWireCases()]; +} diff --git a/Trax.Api.StateMachine/tools/conformance/run.mjs b/Trax.Api.StateMachine/tools/conformance/run.mjs new file mode 100644 index 000000000..fae79ef0c --- /dev/null +++ b/Trax.Api.StateMachine/tools/conformance/run.mjs @@ -0,0 +1,92 @@ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { canonical, kitCases } from "./kit.mjs"; + +const USAGE = + "Usage: node tools/conformance/run.mjs [--only ] (--testee [args...] | --stdio)\n" + + "See conformance/PROTOCOL.md."; + +/** + * Sends each case's request through `exchange` (request → Promise) and compares the answer, without its id, + * with what the case expects. A machine whose load failed fails its remaining cases without sending them. Returns + * the failures, each `{ name, request, expect, got }`; an empty list means the testee passed. + */ +export async function runCases(cases, exchange) { + const failures = []; + const unloaded = new Set(); + let id = 0; + for (const c of cases) { + if (c.request.machine !== undefined && unloaded.has(c.request.machine)) { + failures.push({ ...c, got: { error: `machine ${c.request.machine} did not load` } }); + continue; + } + id += 1; + const { id: answered, ...got } = await exchange({ id, ...c.request }); + if (answered !== id) throw new Error(`the testee answered request ${id} with id ${answered}`); + if (canonical(got) !== canonical(c.expect)) { + failures.push({ ...c, got }); + if (c.request.op === "load") unloaded.add(c.request.machine); + } + } + return failures; +} + +/** One line per failure, naming the case, then what it expected and what the testee answered. */ +export function report(failures, total) { + const lines = failures.map( + (f) => `FAIL ${f.name}\n expected ${canonical(f.expect)}\n got ${canonical(f.got)}`, + ); + lines.push(`${total - failures.length} of ${total} cases passed.`); + return lines.join("\n"); +} + +// Requests go out as lines on `output`; answers come back as lines on `input`, one per request, in order. +function lineExchange(input, output) { + const lines = createInterface({ input })[Symbol.asyncIterator](); + return async (request) => { + output.write(`${JSON.stringify(request)}\n`); + const { value, done } = await lines.next(); + if (done) throw new Error(`the testee closed before answering request ${request.id} (${request.op})`); + return JSON.parse(value); + }; +} + +async function main(argv) { + let only = ""; + let mode; + let command = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--only") only = argv[++i] ?? ""; + else if (argv[i] === "--stdio") mode = "stdio"; + else if (argv[i] === "--testee") { + mode = "spawn"; + command = argv.slice(i + 1); + break; + } else throw new Error(`${USAGE}\nUnknown argument ${argv[i]}.`); + } + if (!mode || (mode === "spawn" && command.length === 0)) throw new Error(USAGE); + + const cases = kitCases().filter((c) => c.name.startsWith(only) || c.request.op === "load"); + let exchange; + let child; + if (mode === "spawn") { + child = spawn(command[0], command.slice(1), { stdio: ["pipe", "pipe", "inherit"] }); + exchange = lineExchange(child.stdout, child.stdin); + } else exchange = lineExchange(process.stdin, process.stdout); + + const failures = await runCases(cases, exchange); + await exchange({ id: cases.length + 1, op: "end" }); + if (child) child.stdin.end(); + console.error(report(failures, cases.length)); + return failures.length === 0 ? 0 : 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (err) => { + console.error(err.message); + process.exit(2); + }, + ); diff --git a/Trax.Api.StateMachine/tools/conformance/run.test.mjs b/Trax.Api.StateMachine/tools/conformance/run.test.mjs new file mode 100644 index 000000000..f6b141e97 --- /dev/null +++ b/Trax.Api.StateMachine/tools/conformance/run.test.mjs @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { kitCases } from "./kit.mjs"; +import { report, runCases } from "./run.mjs"; +import { createTestee, loadEngine } from "./testee-ts.mjs"; + +// The conformance runner over the TypeScript engine: the kit passes through the engine's public exports, both +// in process and as a spawned testee speaking conformance/PROTOCOL.md, and a testee that answers one case wrong +// fails with that case named. + +const here = (file) => fileURLToPath(new URL(file, import.meta.url)); +const cases = kitCases(); +let engine; + +beforeAll(async () => { + engine = await loadEngine(); +}, 60_000); + +describe("the conformance kit", () => { + it("draws on every source", () => { + for (const source of ["/differential/", "/advance/", "/rehydrate/", "/wire-handoff/", "strict-input/", "canonical-wire/"]) + expect(cases.some((c) => c.name.includes(source)), source).toBe(true); + expect(cases.filter((c) => c.request.op === "load").map((c) => c.request.machine)).toEqual([ + "checkout", + "gauge", + "ingest", + "reminder", + "turnstile", + "strict", + ]); + for (const op of ["advance", "outcome", "timer"]) + expect(cases.some((c) => c.request.op === op), op).toBe(true); + }); + + it("passes through the TypeScript engine", async () => { + const handle = createTestee(engine); + const failures = await runCases(cases, async (r) => handle(r)); + expect(failures, report(failures, cases.length)).toEqual([]); + }); + + it("fails a testee that gives one case the wrong code, naming it", async () => { + const handle = createTestee(engine); + let flipped; + const failures = await runCases(cases, async (r) => { + const answer = handle(r); + if (!flipped && answer.reason === "GUARD_FAILED") { + flipped = r.id; + return { ...answer, reason: "INVALID_INPUT" }; + } + return answer; + }); + expect(failures).toHaveLength(1); + expect(failures[0].expect).toEqual({ outcome: "rejected", reason: "GUARD_FAILED" }); + expect(report(failures, cases.length)).toContain(`FAIL ${failures[0].name}`); + }); + + it("drives a spawned testee over the protocol", () => { + const run = spawnSync( + process.execPath, + [here("run.mjs"), "--testee", process.execPath, here("testee-ts.mjs")], + { encoding: "utf8", timeout: 120_000 }, + ); + expect(run.stderr).toContain(`${cases.length} of ${cases.length} cases passed.`); + expect(run.status).toBe(0); + }, 120_000); +}); diff --git a/Trax.Api.StateMachine/tools/conformance/testee-ts.mjs b/Trax.Api.StateMachine/tools/conformance/testee-ts.mjs new file mode 100644 index 000000000..e79fdf42e --- /dev/null +++ b/Trax.Api.StateMachine/tools/conformance/testee-ts.mjs @@ -0,0 +1,96 @@ +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { bundleModule } from "../lib/engine-loader.mjs"; + +// The TypeScript engine as a conformance testee (conformance/PROTOCOL.md): it builds each machine from the IR it is +// loaded with and answers through the package's public exports only. + +const MISSING_DEPENDENCIES = 3; +const engineSrc = fileURLToPath(new URL("../../src", import.meta.url)); + +// The snapshot `canonical` writes: no machine of the kit is named "m", so its wire comes from a machine of its own. +const vectorIr = { + id: "m", + version: 1, + initialState: "S", + initialContext: {}, + states: ["S"], + triggers: [], + committedStates: [], + context: { S: { fields: [] } }, + inputs: {}, + transitions: [], +}; + +/** A handler from one request to its answer, over `engine`, the package's exports. */ +export function createTestee(engine) { + const { SnapshotMachine, machineFromIr } = engine; + const machines = new Map(); + const vectors = new SnapshotMachine(machineFromIr(vectorIr)); + + const step = (core, result) => + result.outcome === "transitioned" + ? { outcome: "transitioned", wire: core.serialize(result.snapshot) } + : { outcome: "rejected", reason: result.reason }; + + const handlers = { + load: (r) => { + machines.set(r.machine, new SnapshotMachine(machineFromIr(r.ir))); + return { loaded: true }; + }, + advance: (r, core) => step(core, core.advance(r.snapshot, r.trigger, r.input ?? undefined)), + outcome: (r, core) => step(core, core.applyOutcome(r.snapshot, r.trigger, r.input ?? undefined)), + timer: (r, core) => step(core, core.applyTimer(r.snapshot, r.trigger)), + rehydrate: (r, core) => { + const result = core.rehydrate(r.text); + return result.result === "ok" + ? { result: "ok", wire: core.serialize(result.snapshot) } + : { result: "error", code: result.code }; + }, + serialize: (r, core) => ({ wire: core.serialize(r.snapshot) }), + canonical: (r) => { + const context = r.context !== undefined + ? JSON.parse(r.context) + : r.stringUnits + ? { s: String.fromCharCode(...r.stringUnits) } + : { [String.fromCharCode(...r.keyUnits)]: 1 }; + return { wire: vectors.serialize({ machine: "m", version: 1, state: "S", context }) }; + }, + end: () => ({}), + }; + + return (request) => { + const handle = handlers[request.op]; + if (!handle) return { id: request.id, unsupported: true }; + const core = request.machine === undefined ? undefined : machines.get(request.machine); + if (request.op !== "load" && request.machine !== undefined && !core) + return { id: request.id, error: `no machine ${request.machine} is loaded` }; + try { + return { id: request.id, ...handle(request, core) }; + } catch (err) { + return { id: request.id, error: err.message }; + } + }; +} + +/** Bundles the engine's source, as the CLI's entrypoints do, and returns its exports. */ +export const loadEngine = () => bundleModule(join(engineSrc, "index.ts")); + +async function main() { + let engine; + try { + engine = await loadEngine(); + } catch (err) { + console.error(err.message); + process.exit(/Could not load esbuild/.test(err.message) ? MISSING_DEPENDENCIES : 1); + } + const handle = createTestee(engine); + for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + process.stdout.write(`${JSON.stringify(handle(request))}\n`); + if (request.op === "end") break; + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceRunnerTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceRunnerTests.cs new file mode 100644 index 000000000..c0d0026d3 --- /dev/null +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/ConformanceRunnerTests.cs @@ -0,0 +1,23 @@ +using AwesomeAssertions; +using Trax.Effect.StateMachine.Tests.Helpers; + +namespace Trax.Effect.StateMachine.Tests.Conformance; + +/// +/// The C# engine through the conformance runner, the same runner and the same cases every other engine is checked +/// with (Trax.Api.StateMachine/conformance/PROTOCOL.md). The runner is started with --stdio and this +/// process answers its requests, so no separate C# executable is needed. +/// +public class ConformanceRunnerTests +{ + [Test] + public void The_CSharp_engine_passes_every_case_of_the_kit() + { + var testee = new ConformanceTestee(); + + var (exitCode, report) = TwinEngine.RunConformance(testee.Answer); + + exitCode.Should().Be(0, report); + report.Should().MatchRegex(@"(\d+) of \1 cases passed\."); + } +} diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs index 621b8ca3d..d0f61df43 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Conformance/StrictInputGoldenTests.cs @@ -126,7 +126,11 @@ private static string Write() private static JsonObject Answer(AdvanceResult result) => result switch { - AdvanceResult.Transitioned => new JsonObject { ["outcome"] = "transitioned" }, + AdvanceResult.Transitioned t => new JsonObject + { + ["outcome"] = "transitioned", + ["wire"] = StrictInputMachine.Machine.Serialize(t.Snapshot), + }, AdvanceResult.Rejected r => new JsonObject { ["outcome"] = "rejected", diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/ConformanceTestee.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/ConformanceTestee.cs new file mode 100644 index 000000000..d2760c87b --- /dev/null +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/ConformanceTestee.cs @@ -0,0 +1,185 @@ +using System.Text.Json.Nodes; +using Trax.Effect.StateMachine.Tests.Fakes; + +namespace Trax.Effect.StateMachine.Tests.Helpers; + +/// +/// The C# engine as a conformance testee (Trax.Api.StateMachine/conformance/PROTOCOL.md). It cannot build a +/// machine from an IR, so it serves the test machines whose IRs the kit holds, by name, and a load checks that +/// the IR it is given is the one its machine exports, apart from the test-only differential block: a committed IR +/// that drifted from its C# source fails there. +/// +public sealed class ConformanceTestee +{ + private static readonly IReadOnlyDictionary Known = new Dictionary< + string, + Served + > + { + ["checkout"] = Served.Of(DeclarativeCheckout.Built), + ["gauge"] = Served.Of(GaugeMachine.Built), + ["ingest"] = Served.Of(IngestMachine.Built), + ["reminder"] = Served.Of(ReminderMachine.Built), + ["turnstile"] = Served.Of(DeclarativeTurnstile.Built), + ["strict"] = Served.Of(StrictInputMachine.Built), + }; + + private readonly Dictionary _loaded = new(); + + /// Answers one request line with one answer line. + public string Answer(string line) + { + var request = (JsonObject)JsonNode.Parse(line)!; + var answer = Handle(request); + answer["id"] = request["id"]?.DeepClone(); + return answer.ToJsonString(); + } + + private JsonObject Handle(JsonObject request) + { + var op = request["op"]!.GetValue(); + if (op == "end") + return new JsonObject(); + if (op == "canonical") + return new JsonObject { ["wire"] = Canonical(request) }; + if (op == "load") + return Load(request); + + var name = request["machine"]!.GetValue(); + if (!_loaded.TryGetValue(name, out var machine)) + return new JsonObject { ["error"] = $"no machine {name} is loaded" }; + + return op switch + { + "advance" => machine.Step(op, SnapshotOf(request), Trigger(request), request["input"]), + "outcome" => machine.Step(op, SnapshotOf(request), Trigger(request), request["input"]), + "timer" => machine.Step(op, SnapshotOf(request), Trigger(request), null), + "rehydrate" => machine.Rehydrate(request["text"]!.GetValue()), + "serialize" => new JsonObject { ["wire"] = machine.Serialize(SnapshotOf(request)) }, + _ => new JsonObject { ["unsupported"] = true }, + }; + } + + private JsonObject Load(JsonObject request) + { + var name = request["machine"]!.GetValue(); + if (!Known.TryGetValue(name, out var machine)) + return new JsonObject { ["error"] = $"this engine has no machine {name}" }; + if (!JsonNode.DeepEquals(Behaviour(request["ir"]), Behaviour(JsonNode.Parse(machine.Ir)))) + return new JsonObject + { + ["error"] = + $"the {name} IR differs from what this engine's {name} exports: {machine.Ir}", + }; + _loaded[name] = machine; + return new JsonObject { ["loaded"] = true }; + } + + // The IR without its differential block, which is test-only fuzzing input, stripped from a runtime machine: two + // IRs that differ only there describe the same machine. + private static JsonNode? Behaviour(JsonNode? ir) + { + var copy = ir?.DeepClone() as JsonObject; + copy?.Remove("differential"); + return copy; + } + + private static string Trigger(JsonObject request) => request["trigger"]!.GetValue(); + + private static Snapshot SnapshotOf(JsonObject request) + { + var snapshot = (JsonObject)request["snapshot"]!; + return new Snapshot + { + Machine = snapshot["machine"]!.GetValue(), + Version = snapshot["version"]!.GetValue(), + State = snapshot["state"]!.GetValue(), + Context = (JsonObject)snapshot["context"]!.DeepClone(), + }; + } + + // A lone surrogate has no UTF-8 encoding and System.Text.Json refuses to read one, so such a string comes as + // UTF-16 code units. + private static string Canonical(JsonObject request) + { + var context = + request["context"] is { } text ? (JsonObject)JsonNode.Parse(text.GetValue())! + : request["stringUnits"] is JsonArray stringUnits + ? new JsonObject { ["s"] = FromUnits(stringUnits) } + : new JsonObject { [FromUnits((JsonArray)request["keyUnits"]!)] = 1 }; + return TestTurnstile.Machine.Serialize( + new Snapshot + { + Machine = "m", + Version = 1, + State = "S", + Context = context, + } + ); + } + + private static string FromUnits(JsonArray units) => + new(units.Select(u => (char)u!.GetValue()).ToArray()); + + /// One served machine, its type arguments closed over. + private sealed record Served( + string Ir, + Func Apply, + Func Read, + Func Write + ) + { + public static Served Of(BuiltMachine built) + where TState : struct, Enum + where TTrigger : struct, Enum + { + var engine = built.Engine; + return new Served( + IrExporter.Export(built), + (op, snapshot, trigger, input) => + op switch + { + "outcome" => engine.AdvanceOutcome(snapshot, trigger, input?.DeepClone()), + "timer" => engine.AdvanceTimer(snapshot, trigger), + _ => engine.Advance(snapshot, trigger, input?.DeepClone()), + }, + engine.Rehydrate, + engine.Serialize + ); + } + + public JsonObject Step(string op, Snapshot snapshot, string trigger, JsonNode? input) => + Apply(op, snapshot, trigger, input) switch + { + AdvanceResult.Transitioned t => new JsonObject + { + ["outcome"] = "transitioned", + ["wire"] = Write(t.Snapshot), + }, + AdvanceResult.Rejected r => new JsonObject + { + ["outcome"] = "rejected", + ["reason"] = r.Reason.ToWire(), + }, + var other => new JsonObject { ["error"] = $"unexpected result {other}" }, + }; + + public JsonObject Rehydrate(string text) => + Read(text) switch + { + RehydrationResult.Ok ok => new JsonObject + { + ["result"] = "ok", + ["wire"] = Write(ok.Snapshot), + }, + RehydrationResult.Error e => new JsonObject + { + ["result"] = "error", + ["code"] = e.Code.ToWire(), + }, + var other => new JsonObject { ["error"] = $"unexpected result {other}" }, + }; + + public string Serialize(Snapshot snapshot) => Write(snapshot); + } +} diff --git a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs index e838854b7..0902d1c0e 100644 --- a/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs +++ b/Trax.Effect/tests/Trax.Effect.StateMachine.Tests/Helpers/TwinEngine.cs @@ -113,6 +113,42 @@ public static IReadOnlyList> ValidateIrs(IReadOnlyList + /// Runs the conformance runner (tools/conformance/run.mjs --stdio) with as its + /// testee: each request line the runner writes is answered with the line returns. + /// Returns the runner's exit code (0 when every case passed) and its report. + /// + public static (int ExitCode, string Report) RunConformance(Func answer) + { + var engineRoot = EngineRoot(); + var start = new ProcessStartInfo("node", "tools/conformance/run.mjs --stdio") + { + WorkingDirectory = engineRoot, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var process = Process.Start(start)!; + var stderr = process.StandardError.ReadToEndAsync(); + var conversation = Task.Run(() => + { + while (process.StandardOutput.ReadLine() is { } request) + { + process.StandardInput.WriteLine(answer(request)); + process.StandardInput.Flush(); + } + }); + + // The test owns its wait (ADR 0014): the runner finishes well within it, or the test fails. + if (!conversation.Wait(NodeTimeout) || !process.WaitForExit(NodeTimeout)) + { + process.Kill(entireProcessTree: true); + Assert.Fail($"tools/conformance/run.mjs did not finish within {NodeTimeout}."); + } + return (process.ExitCode, stderr.GetAwaiter().GetResult()); + } + private static string EngineRoot() { if (!NodeIsAvailable()) From 2f729f7b188be57d2972e728610bc89859a21e06 Mon Sep 17 00:00:00 2001 From: Theauxm <43664045+Theauxm@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:39:47 -0600 Subject: [PATCH 5/5] test(surface): wait until the server listens before releasing the run the timeline follows A_running_runs_steps_are_pushed_to_its_timeline_as_they_end released the held run as soon as the page sent its OnJunctionEvent subscribe frame. graphql-ws acknowledges no single subscription, so the server could still be registering it when the junction completed, and that event reached nobody: the test then timed out waiting for the push (three CI runs on 2026-10-20, on branches that touch neither the console nor subscriptions). The test now publishes a probe junction event for the run through the host's junction event handlers until the page receives it, as the Api's JunctionEventsE2ETests already does with WaitUntilListeningAsync, and releases the run only then. --- .../Fixtures/PageChecks.cs | 18 ++++++++++ .../Fixtures/Seed.cs | 36 +++++++++++++++++++ .../Tests/RunActionsBrowserTests.cs | 6 ++++ 3 files changed, 60 insertions(+) diff --git a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/PageChecks.cs b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/PageChecks.cs index d3cccaa61..b78170d36 100644 --- a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/PageChecks.cs +++ b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/PageChecks.cs @@ -119,6 +119,24 @@ public Task PushedAsync(string field, params string[] containing) => $"the server pushes {field} with {string.Join(", ", containing)}" ); + /// + /// Waits until the server is listening for the page's subscription, by calling until the + /// page is pushed an event of holding every text given. The page sending its + /// subscribe frame () does not show that: graphql-ws acknowledges no single + /// subscription, and an event published before the server has registered it reaches nobody. + /// + public async Task ListeningAsync(Func probe, string field, params string[] containing) + { + var pushed = PushedAsync(field, containing); + while (!pushed.IsCompleted) + { + await probe(); + // allowed-delay: re-probe interval, bounded by PushedAsync's own timeout; WhenAny wakes on receipt. + await Task.WhenAny(pushed, Task.Delay(100)); + } + await pushed; + } + private static async Task Bounded(Task seen, string what) { try diff --git a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/Seed.cs b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/Seed.cs index 0dbf73751..4c6104219 100644 --- a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/Seed.cs +++ b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Fixtures/Seed.cs @@ -11,6 +11,7 @@ using Trax.Effect.Models.Metadata; using Trax.Effect.Models.PersistedOperation; using Trax.Effect.Models.WorkQueue; +using Trax.Effect.Services.TrainEventBroadcaster; using Trax.Effect.StateMachine.Persistence; using Trax.Mediator.Services.TrainExecution; using Trax.Mediator.Services.TrustedExecution; @@ -251,6 +252,41 @@ public async Task> HeldRunsAsync(int count) return (run, () => Holds.Release(key)); } + /// + /// Publishes a junction event for named , through every + /// junction event handler the host registered, as a real step's would be. A page following the run receives it + /// once the server's subscription is live: see . + /// + public async Task ProbeJunctionEventAsync(Metadata run) + { + var message = new TrainLifecycleEventMessage( + MetadataId: run.Id, + ExternalId: run.ExternalId, + TrainName: typeof(TTrain).FullName!, + TrainState: nameof(TrainState.InProgress), + Timestamp: DateTime.UtcNow, + FailureJunction: null, + FailureReason: null, + EventType: TrainLifecycleEventMessage.JunctionStartedEventType, + Executor: null, + Output: null + ) + { + Junction = new JunctionEventPayload( + Position: 999, + Kind: JunctionRunKind.Junction, + Name: ProbeJunction, + State: JunctionRunState.InProgress, + StartedAt: DateTime.UtcNow + ), + }; + foreach (var handler in host.Services.GetServices()) + await handler.HandleAsync(message, CancellationToken.None); + } + + /// The name of the step publishes. + public const string ProbeJunction = "probe"; + /// /// A manifest that runs only when triggered, in when one is named: what Queue now, /// Queue later, Edit and the on/off switches act on. diff --git a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Tests/RunActionsBrowserTests.cs b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Tests/RunActionsBrowserTests.cs index 40f58a4bc..6b5471ba7 100644 --- a/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Tests/RunActionsBrowserTests.cs +++ b/Trax.Samples/tests/Trax.Samples.ApiDashboard.Browser/Tests/RunActionsBrowserTests.cs @@ -109,6 +109,12 @@ public async Task A_running_runs_steps_are_pushed_to_its_timeline_as_they_end() var page = new RunPage(Console, run.Id, nameof(IReconcileAccountTrain)); await page.OpenAsync(Caller.Operator); await Checks.SubscribedAsync("OnJunctionEvent", $"\"metadataId\":{run.Id}"); + await Checks.ListeningAsync( + () => Seed.ProbeJunctionEventAsync(run), + "onJunctionEvent", + $"\"metadataId\":{run.Id}", + $"\"{Fixtures.Seed.ProbeJunction}\"" + ); release();