← .NET Test Clinic
API
Tokens

Run .NET Test Clinic from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the C# test class for free - it detects the framework (MSTest, xUnit, NUnit or TUnit), counts every test and every data row, and reports async void tests, data rows that do not match their parameters, tests that assert nothing, sleeps, swallowed exceptions, blocking on .Result, near-duplicate tests and each framework's own traps, each with its rule id, line and fix - in cstscan.js. The run reviews and rewrites the class, or ports it; the page then holds the reply to its own scan and re-scans the class that comes back, checking that no test or data row was lost (csreply.js). Not affiliated with Microsoft, the .NET Foundation or the framework authors.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"dotnet-test-clinic"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, code, framework, facts) is missing or not a string. Send every field as a string; target and focus may be "".
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - review (judge the class: a verdict, findings with fixes, a call on every scan finding, and the whole class rewritten in its own framework) and port (the whole class moved to another framework, with the attribute map, the NuGet packages and the behaviour differences). A missing or unknown task is answered as review, with a caveat saying so. Both lanes send the same fields; only a port fills target.

The input

Every field is a string.

FieldRequiredWhat
taskyesreview or port. The model answers that task and only that task.
codeyesthe test class, the whole .cs file. The page clips a long file (over 26,000 characters) by keeping the head and the tail and putting a // ... N lines (a-b) omitted by .NET Test Clinic to fit the run ... line in the middle; the model keeps that line where it was and will not guess what it hid.
frameworkyesthe framework the page detected: mstest, xunit, nunit or tunit. A review rewrites the class in this framework; a port starts from it.
targetno (may be "")for port, the framework to port to - one of the four, and not the same as framework. Empty ("") for review.
factsyesthe page's own scan of the same code: framework, class, every test with its data rows and whether it asserts anything, a prescan verdict, and every finding as rule | severity | line | test | text. See the facts format.
focusnowhat the developer wants weighed, for example "We are moving the solution to TUnit on .NET 9." May be empty; when given, the summary says how it changed the answer.
retry_notenopresent only on a reformat retry: the previous reply could not be read, and this says how to answer again. Leave it out otherwise.

The facts format

facts is the page's scan, written as plain text. An API caller may send their own short summary of the class instead, but the scan's format gets the most out of a run: the review answers every error and warn rule id it finds there (one prescan entry per id), and both lanes keep every test named on the Tests: line. The format, line by line:

Framework: <MSTest|xUnit|NUnit|TUnit>; class <ClassName>< in <Namespace>>.
Tests: <n> test methods, <n> runs counting data rows: <TestName>< xN | (data source)>< [no assert]>; ...
Prescan verdict: <ship|refactor|rework> (<n> errors, <n> warnings, <n> notes).
Port target: <MSTest|xUnit|NUnit|TUnit>.            (port only)
Clipped: <only when the code was clipped>
Findings (rule | severity | line | test | text):
- <rule id> | <error|warn|info> | line <n> | <test name or -> | <title>< (note)> | <the source line>

A test with data rows is written Name xN (each row is a run), one fed by a data source Name (data source), and a class with no findings gets the single line - none. The prescan verdict is rework at two errors or more, or when errors and warnings land in three of the six areas (assertions, data-driven, async and timing, setup and lifecycle, isolation, conventions); refactor on any error or warning; ship otherwise. A real example - the facts the page sends for the MSTest sample used in the first worked example below:

Framework: MSTest; class OrderServiceTests in Shop.Tests.
Tests: 7 test methods, 8 runs counting data rows: Total; Discount10; Discount20; Discount50; NegativeDiscount [no assert]; Shipping_ByWeight_IsCharged x2; Submit_SendsConfirmation.
Prescan verdict: rework (2 errors, 7 warnings, 5 notes).
Findings (rule | severity | line | test | text):
- CT-ARITY | error | line 60 | Shipping_ByWeight_IsCharged | Data row does not match the parameters (DataRow gives 3 values for 2 parameters.) | public void Shipping_ByWeight_IsCharged(int kg, decimal expected)
- CT-ASYNCVOID | error | line 66 | Submit_SendsConfirmation | async void test | public async void Submit_SendsConfirmation()
- MS-ARGORDER | warn | line 22 | Total | Assert.AreEqual(actual, expected) | Assert.AreEqual(result, 5m);
- CT-DUPLICATE | warn | line 26 | - | Near-duplicate tests (3 tests differ only in their values.) | Discount10, Discount20, Discount50
- MS-ISTRUEEQ | warn | line 30 | Discount10 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 90m);
- MS-ISTRUEEQ | warn | line 38 | Discount20 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 80m);
- MS-ISTRUEEQ | warn | line 46 | Discount50 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 50m);
- MS-EXPECTEDEX | warn | line 51 | NegativeDiscount | [ExpectedException] | public void NegativeDiscount()
- CT-SLEEP | warn | line 70 | Submit_SendsConfirmation | Thread.Sleep / fixed delay in a test | Thread.Sleep(500);
- MS-SEALED | info | line 8 | - | Test class not sealed | public class OrderServiceTests
- MS-TESTINIT | info | line 13 | - | [TestInitialize] that only sets fields | public void Init()
- CT-NAMING | info | line 19 | - | Test names not MethodName_Scenario_ExpectedBehavior (6 of 7 test names without the pattern.) | Total, Discount10, Discount20, Discount50
- MS-DATATESTMETHOD | info | line 60 | Shipping_ByWeight_IsCharged | [DataTestMethod] | public void Shipping_ByWeight_IsCharged(int kg, decimal expected)
- CT-CONSOLE | info | line 71 | Submit_SendsConfirmation | Console output in a test | Console.WriteLine(order.Status);

Rule ids the scan uses, with their severity (CT- rules apply to every framework; MS-, XU-, NU- and TU- rules to MSTest, xUnit, NUnit and TUnit only):

RuleSeverityAreaTitle
CT-ASYNCVOIDerrorAsync and timingasync void test
CT-ARITYerrorData-drivenData row does not match the parameters
CT-NOASSERTwarnAssertionsTest with no assertion
CT-SLEEPwarnAsync and timingThread.Sleep / fixed delay in a test
CT-RESULTwarnAsync and timingBlocking on .Result / .Wait()
CT-TRYCATCHwarnAssertionsException swallowed in a test
CT-DUPLICATEwarnData-drivenNear-duplicate tests
CT-NONDETinfoIsolationClock, random or new Guid in a test
CT-CONSOLEinfoIsolationConsole output in a test
CT-NAMINGinfoConventionsTest names not MethodName_Scenario_ExpectedBehavior
MS-EXPECTEDEXwarnAssertions[ExpectedException]
MS-ARGORDERwarnAssertionsAssert.AreEqual(actual, expected)
MS-ISTRUEEQwarnAssertionsAssert.IsTrue(a == b)
MS-SEALEDinfoConventionsTest class not sealed
MS-DATATESTMETHODinfoConventions[DataTestMethod]
MS-TESTINITinfoConventions[TestInitialize] that only sets fields
MS-NULLCTXinfoConventionsNullable or null! TestContext
MS-SINGLEinfoAssertions.Single() in a test
XU-EQTRUEwarnAssertionsAssert.True(a == b)
XU-FACTPARAMSerrorData-driven[Fact] with parameters
XU-THEORYNODATAerrorData-driven[Theory] with no data
XU-SETUPATTRerrorSetup and lifecycleSetup attribute xUnit ignores
NU-CLASSICwarnAssertionsNUnit classic assert
NU-EXPECTEDEXerrorAssertions[ExpectedException] in NUnit
TU-NOAWAITerrorAsync and timingTUnit assertion not awaited
TU-SYNCTESTerrorAsync and timingTUnit test that is not async Task
TU-FOREIGNerrorSetup and lifecyclexUnit/MSTest attribute in a TUnit file

A finding the scan cannot see comes back with the rule NEW.

The standard the model applies

The four source skills - @github/csharp-mstest, csharp-xunit, csharp-nunit and csharp-tunit - are the only standard:

House rules

The replies

Both lanes return the whole class as code: a review in a rewrite object, a port in a file object, each with name (such as OrderServiceTests.cs) and code.

review

verdict is ship (idiomatic and correct), refactor (works, with smells or one real defect) or rework (tests that cannot fail, cannot run as written, or hide failures). A scan error the model agrees with rules out ship.

  1. summary - two or three sentences: what the class tests, the verdict and the first thing to fix.
  2. findings - most severe first: rule (the facts' rule id, or NEW), severity (high, medium or low), title, where (such as line 66 or a test name), why (one or two sentences about this class), fix (the corrected lines).
  3. prescan - one entry per error and warn rule id in the facts: rule, call (agree or disagree), note (why, one sentence).
  4. rewrite - name and code: the whole class in the same framework, including what did not change.
  5. kept - anything deliberately left as it was: rule, what, why.
  6. caveats - what could not be seen or verified.
{
  "task": "review",
  "verdict": "refactor",
  "summary": "",
  "findings": [ { "rule": "CT-ASYNCVOID", "severity": "high", "title": "", "where": "line 66", "why": "", "fix": "" } ],
  "prescan": [ { "rule": "CT-ASYNCVOID", "call": "agree", "note": "" } ],
  "rewrite": { "name": "OrderServiceTests.cs", "code": "" },
  "kept": [ { "rule": "", "what": "", "why": "" } ],
  "caveats": [ "" ]
}

The page holds it to its own scan: every scan error and warning answered by rule id (in findings or prescan), no rule id the scan did not report other than NEW, no ship over an undisputed error, no rework on a clean scan without a high-severity finding, and no claim that the tests were built or run. Then it re-scans the rewrite: it must still be the same framework and the same class name, no test may be lost by name, the number of runs (data rows included) may not drop, a scan error or warning still present must be listed in kept, no new problem may appear, and a clipped input must keep its omitted-lines line. Each miss is shown next to the reply as a note.

port

The class moved to target, keeping its meaning, with the scan's errors fixed on the way and everything else as close to the original as the target allows.

  1. summary - two sentences.
  2. file - name and code: the whole class in the target framework, with the target's using directives.
  3. map - one entry per construct changed: from and to (such as [DataRow(1, 10)] -> [InlineData(1, 10)]).
  4. packages - the NuGet packages the test project needs for the target (such as xunit, xunit.runner.visualstudio, Microsoft.NET.Test.Sdk).
  5. notes - behaviour differences the developer must know (setup order, parallelism, shared state, async assertions).
  6. caveats - what could not be seen.
{
  "task": "port",
  "summary": "",
  "file": { "name": "OrderServiceTests.cs", "code": "" },
  "map": [ { "from": "[TestMethod]", "to": "[Fact]" } ],
  "packages": [ "xunit" ],
  "notes": [ "" ],
  "caveats": [ "" ]
}

The page re-scans the ported file: it must read as the target framework, carry no attribute of another framework (a [Fact] left in a TUnit file is named), keep the class name and every test name, run no fewer cases than the original, and not carry the original's CT- errors (such as CT-ASYNCVOID or CT-ARITY) into the target; a clipped input must keep its omitted-lines line. packages must include the target's own package (MSTest, MSTest.TestFramework or MSTest.Sdk; xunit or xunit.v3; NUnit; TUnit), and the notes may not claim the port was built or run. A port to TUnit also gets an informational note that TUnit needs .NET 8 or later and runs tests in parallel by default. It shows the diff and offers a .patch.

Worked examples - one review, one port

Two real runs. Each input is exactly what the page sends; each reply is a real reply, abbreviated for reading: long strings end in ... and the class shows only its first lines. The real replies are complete JSON with every field in full. The page's reply check raised no warning on either (the port gets only the informational TUnit note).

OrderServiceTests (MSTest) - review

An MSTest class with seven tests and eight runs: an async void test, a data row with three values for two parameters, a reversed AreEqual, three near-duplicate discount tests using Assert.IsTrue(a == b), an [ExpectedException] and a Thread.Sleep - prescan rework. Input:

{
 "task": "review",
 "code": "using System;\nusing System.Threading;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\n\nnamespace Shop.Tests\n{\n    [TestClass]\n    public class OrderServiceTests\n    {\n        private OrderService service;\n\n        [TestInitialize]\n        public void Init()\n        {\n            service = new OrderService(new InMemoryOrders());\n        }\n\n        [TestMethod]\n        public void Total()\n        {\n            var result = service.Total(new[] { 2m, 3m });\n            Assert.AreEqual(result, 5m);\n        }\n\n        [TestMethod]\n        public void Discount10()\n        {\n            var order = service.Create(100m);\n            service.ApplyDiscount(order, 10);\n            Assert.IsTrue(order.Total == 90m);\n        }\n\n        [TestMethod]\n        public void Discount20()\n        {\n            var order = service.Create(100m);\n            service.ApplyDiscount(order, 20);\n            Assert.IsTrue(order.Total == 80m);\n        }\n\n        [TestMethod]\n        public void Discount50()\n        {\n            var order = service.Create(100m);\n            service.ApplyDiscount(order, 50);\n            Assert.IsTrue(order.Total == 50m);\n        }\n\n        [TestMethod]\n        [ExpectedException(typeof(ArgumentOutOfRangeException))]\n        public void NegativeDiscount()\n        {\n            var order = service.Create(100m);\n            service.ApplyDiscount(order, -5);\n        }\n\n        [DataTestMethod]\n        [DataRow(1, 10)]\n        [DataRow(2, 20, 3)]\n        public void Shipping_ByWeight_IsCharged(int kg, decimal expected)\n        {\n            Assert.AreEqual(expected, service.Shipping(kg));\n        }\n\n        [TestMethod]\n        public async void Submit_SendsConfirmation()\n        {\n            var order = service.Create(10m);\n            await service.SubmitAsync(order);\n            Thread.Sleep(500);\n            Console.WriteLine(order.Status);\n            Assert.AreEqual(\"Submitted\", order.Status);\n        }\n    }\n}",
 "framework": "mstest",
 "target": "",
 "facts": "Framework: MSTest; class OrderServiceTests in Shop.Tests.\nTests: 7 test methods, 8 runs counting data rows: Total; Discount10; Discount20; Discount50; NegativeDiscount [no assert]; Shipping_ByWeight_IsCharged x2; Submit_SendsConfirmation.\nPrescan verdict: rework (2 errors, 7 warnings, 5 notes).\nFindings (rule | severity | line | test | text):\n- CT-ARITY | error | line 60 | Shipping_ByWeight_IsCharged | Data row does not match the parameters (DataRow gives 3 values for 2 parameters.) | public void Shipping_ByWeight_IsCharged(int kg, decimal expected)\n- CT-ASYNCVOID | error | line 66 | Submit_SendsConfirmation | async void test | public async void Submit_SendsConfirmation()\n- MS-ARGORDER | warn | line 22 | Total | Assert.AreEqual(actual, expected) | Assert.AreEqual(result, 5m);\n- CT-DUPLICATE | warn | line 26 | - | Near-duplicate tests (3 tests differ only in their values.) | Discount10, Discount20, Discount50\n- MS-ISTRUEEQ | warn | line 30 | Discount10 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 90m);\n- MS-ISTRUEEQ | warn | line 38 | Discount20 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 80m);\n- MS-ISTRUEEQ | warn | line 46 | Discount50 | Assert.IsTrue(a == b) | Assert.IsTrue(order.Total == 50m);\n- MS-EXPECTEDEX | warn | line 51 | NegativeDiscount | [ExpectedException] | public void NegativeDiscount()\n- CT-SLEEP | warn | line 70 | Submit_SendsConfirmation | Thread.Sleep / fixed delay in a test | Thread.Sleep(500);\n- MS-SEALED | info | line 8 | - | Test class not sealed | public class OrderServiceTests\n- MS-TESTINIT | info | line 13 | - | [TestInitialize] that only sets fields | public void Init()\n- CT-NAMING | info | line 19 | - | Test names not MethodName_Scenario_ExpectedBehavior (6 of 7 test names without the pattern.) | Total, Discount10, Discount20, Discount50\n- MS-DATATESTMETHOD | info | line 60 | Shipping_ByWeight_IsCharged | [DataTestMethod] | public void Shipping_ByWeight_IsCharged(int kg, decimal expected)\n- CT-CONSOLE | info | line 71 | Submit_SendsConfirmation | Console output in a test | Console.WriteLine(order.Status);",
 "focus": ""
}

Reply (abbreviated). The review agreed with all seven error and warning rule ids, kept the verdict at rework, folded Discount10, Discount20 and Discount50 into one data-driven test that keeps the old names as display names, and listed the naming finding under kept; the rewrite still runs eight cases:

{
 "task": "review",
 "verdict": "rework",
 "summary": "OrderServiceTests checks OrderService totals, percentage discounts, the negative-discount guard, weight-based shipping and order submission. Verdict rework: Submit_SendsConfirmation is async void, so MSTest does not await it and its assertion can never fail the run, and the second Shipping data row passes three values to a two-parameter method, so that row cannot run. Fix those two first: return Task from Submit_SendsConfirmation and make the row [DataRow(2, 20)].",
 "findings": [
  {
   "rule": "CT-ASYNCVOID",
   "severity": "high",
   "title": "async void test hides its own failure",
   "where": "line 66",
   "why": "MSTest cannot await an async void method: it reports the test as finished at the first await, so ...",
   "fix": "public async Task Submit_SendsConfirmation()"
  },
  {
   "rule": "CT-ARITY",
   "severity": "high",
   "title": "Data row with three values for two parameters",
   "where": "line 59",
   "why": "[DataRow(2, 20, 3)] gives three arguments to Shipping_ByWeight_IsCharged(int kg, decimal expected) ...",
   "fix": "[DataRow(2, 20)]"
  },
  {
   "rule": "CT-SLEEP",
   "severity": "medium",
   "title": "Fixed sleep after an awaited call",
   "where": "line 70",
   "why": "SubmitAsync is already awaited on line 69, so Thread.Sleep(500) only adds half a second and blocks ...",
   "fix": "await service.SubmitAsync(order);\nAssert.AreEqual(\"Submitted\", order.Status);"
  },
  {
   "rule": "MS-EXPECTEDEX",
   "severity": "medium",
   "title": "[ExpectedException] instead of Assert.ThrowsExactly",
   "where": "line 50",
   "why": "[ExpectedException] passes if anything in the method throws ArgumentOutOfRangeException, including ...",
   "fix": "var order = service.Create(100m);\nAssert.ThrowsExactly<ArgumentOutOfRangeException>(() => ..."
  },
  {
   "rule": "MS-ARGORDER",
   "severity": "medium",
   "title": "Expected and actual swapped",
   "where": "line 22",
   "why": "Assert.AreEqual(result, 5m) passes the actual value as expected, so a failure message reads ...",
   "fix": "Assert.AreEqual(5m, result);"
  },
  {
   "rule": "MS-ISTRUEEQ",
   "severity": "medium",
   "title": "Assert.IsTrue(a == b) hides both values",
   "where": "lines 30, 38, 46",
   "why": "Assert.IsTrue(order.Total == 90m) only reports 'Assert.IsTrue failed', not the total that came ...",
   "fix": "Assert.AreEqual<decimal>(expectedTotal, order.Total);"
  },
  {
   "rule": "CT-DUPLICATE",
   "severity": "low",
   "title": "Three discount tests differ only in their numbers",
   "where": "lines 25-47 (Discount10, Discount20, Discount50)",
   "why": "The three methods repeat the same Arrange-Act-Assert with a different percent and total; one ...",
   "fix": "[TestMethod]\n[DataRow(10, 90, DisplayName = \"Discount10\")]\n..."
  },
  {
   "rule": "NEW",
   "severity": "low",
   "title": "Decimal expectations passed as int attribute arguments",
   "where": "line 58 and the discount rows",
   "why": "C# attributes cannot hold decimal constants, so [DataRow(1, 10)] delivers an int to a decimal ...",
   "fix": "public void Shipping_ByWeight_IsCharged(int kg, int expected)\n{\n..."
  },
  {
   "rule": "MS-TESTINIT",
   "severity": "low",
   "title": "[TestInitialize] used for synchronous field setup",
   "where": "line 13",
   "why": "Init only assigns a field; MSTest creates a new instance per test, so the constructor does the same ...",
   "fix": "private readonly OrderService service;\n\n..."
  },
  {
   "rule": "MS-SEALED",
   "severity": "low",
   "title": "Test class not sealed",
   "where": "line 8",
   "why": "Nothing derives from this class; sealing it is the MSTest default and lets the analyzers and ...",
   "fix": "public sealed class OrderServiceTests"
  },
  {
   "rule": "MS-DATATESTMETHOD",
   "severity": "low",
   "title": "[DataTestMethod] is redundant",
   "where": "line 57",
   "why": "[TestMethod] runs [DataRow] cases in MSTest 3 and later, and [DataTestMethod] is obsolete in MSTest ...",
   "fix": "[TestMethod]"
  },
  {
   "rule": "CT-CONSOLE",
   "severity": "low",
   "title": "Console output in a test",
   "where": "line 71",
   "why": "Console.WriteLine(order.Status) is noise once the assertion reports the actual status on failure ...",
   "fix": "Remove the line; Assert.AreEqual(\"Submitted\", order.Status) already prints the actual ..."
  }
 ],
 "prescan": [
  {
   "rule": "CT-ARITY",
   "call": "agree",
   "note": "[DataRow(2, 20, 3)] on line 59 has one value more than the two parameters on line 60; the row ..."
  },
  {
   "rule": "CT-ASYNCVOID",
   "call": "agree",
   "note": "Submit_SendsConfirmation on line 66 is async void, so its assertion cannot fail the test; it must ..."
  },
  {
   "rule": "MS-ARGORDER",
   "call": "agree",
   "note": "Line 22 passes the computed result as expected and the literal 5m as actual."
  },
  {
   "rule": "CT-DUPLICATE",
   "call": "agree",
   "note": "Discount10, Discount20 and Discount50 differ only in percent and expected total, so they fold into ..."
  },
  {
   "rule": "MS-ISTRUEEQ",
   "call": "agree",
   "note": "Lines 30, 38 and 46 compare with == inside Assert.IsTrue, losing both values on failure."
  },
  {
   "rule": "MS-EXPECTEDEX",
   "call": "agree",
   "note": "NegativeDiscount relies on [ExpectedException], which also accepts a throw from Create and is gone ..."
  },
  {
   "rule": "CT-SLEEP",
   "call": "agree",
   "note": "Thread.Sleep(500) on line 70 follows an awaited SubmitAsync and only slows the test or masks a race."
  }
 ],
 "rewrite": {
  "name": "OrderServiceTests.cs",
  "code": "using System;\nusing System.Threading.Tasks;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\n\nnamespace Shop.Tests\n{\n    [TestClass]\n    public sealed class OrderServiceTests\n    {\n        private readonly OrderService service;\n\n        public OrderServiceTests()\n        {\n            service = new OrderService(new InMemoryOrders());\n        }\n..."
 },
 "kept": [
  {
   "rule": "CT-NAMING",
   "what": "The test names Total, NegativeDiscount, Shipping_ByWeight_IsCharged and Submit_SendsConfirmation ...",
   "why": "Renaming tests breaks history and filters in CI; the new discount method uses the ..."
  },
  {
   "rule": "NEW",
   "what": "The InMemoryOrders store built per test in the constructor.",
   "why": "It is created fresh for every test, so tests stay independent; mocking it through an interface is ..."
  }
 ],
 "caveats": [
  "OrderService, InMemoryOrders and their members (Total, Create, ApplyDiscount, Shipping ...",
  "The stray 3 in [DataRow(2, 20, 3)] may have been meant as a separate row such as a 3 kg case; its ...",
  "Removing Thread.Sleep assumes SubmitAsync sets Status before its Task completes; if Status is set ...",
  "Assert.ThrowsExactly needs MSTest 3.8 or later; on an older MSTest use ..."
 ]
}

CartTests (xUnit) - port to TUnit

An xUnit class with four tests and five runs - a swallowed exception with no assertion, two blocking calls and an Assert.True(a == b) - sent to the port lane with target tunit and a focus on moving to TUnit on .NET 9. Input:

{
 "task": "port",
 "code": "using System;\nusing System.Threading.Tasks;\nusing Xunit;\n\npublic class CartTests : IDisposable\n{\n    private readonly Cart _cart = new Cart(new FakePrices());\n\n    [Fact]\n    public void Add_NewItem_IncreasesCount()\n    {\n        _cart.Add(\"apple\", 2);\n        Assert.True(_cart.Count == 2);\n    }\n\n    [Theory]\n    [InlineData(\"apple\", 1, 0.5)]\n    [InlineData(\"pear\", 3, 2.1)]\n    public void Total_ForItems_UsesUnitPrice(string sku, int qty, double expected)\n    {\n        _cart.Add(sku, qty);\n        Assert.Equal(expected, _cart.Total, 2);\n    }\n\n    [Fact]\n    public void Checkout_EmptyCart_Throws()\n    {\n        try\n        {\n            _cart.CheckoutAsync().Wait();\n        }\n        catch (Exception)\n        {\n        }\n    }\n\n    [Fact]\n    public void Checkout_WithItems_StampsTime()\n    {\n        _cart.Add(\"apple\", 1);\n        var receipt = _cart.CheckoutAsync().Result;\n        Assert.True(receipt.At <= DateTime.Now);\n    }\n\n    public void Dispose() => _cart.Clear();\n}",
 "framework": "xunit",
 "target": "tunit",
 "facts": "Framework: xUnit; class CartTests.\nTests: 4 test methods, 5 runs counting data rows: Add_NewItem_IncreasesCount; Total_ForItems_UsesUnitPrice x2; Checkout_EmptyCart_Throws [no assert]; Checkout_WithItems_StampsTime.\nPrescan verdict: refactor (0 errors, 5 warnings, 1 note).\nPort target: TUnit.\nFindings (rule | severity | line | test | text):\n- XU-EQTRUE | warn | line 13 | Add_NewItem_IncreasesCount | Assert.True(a == b) | Assert.True(_cart.Count == 2);\n- CT-NOASSERT | warn | line 26 | Checkout_EmptyCart_Throws | Test with no assertion | public void Checkout_EmptyCart_Throws()\n- CT-TRYCATCH | warn | line 28 | Checkout_EmptyCart_Throws | Exception swallowed in a test | try\n- CT-RESULT | warn | line 30 | Checkout_EmptyCart_Throws | Blocking on .Result / .Wait() | _cart.CheckoutAsync().Wait();\n- CT-RESULT | warn | line 41 | Checkout_WithItems_StampsTime | Blocking on .Result / .Wait() | var receipt = _cart.CheckoutAsync().Result;\n- CT-NONDET | info | line 42 | Checkout_WithItems_StampsTime | Clock, random or new Guid in a test | Assert.True(receipt.At <= DateTime.Now);",
 "focus": "We are moving the solution to TUnit on .NET 9."
}

Reply (abbreviated). Every test is now async Task with awaited assertions, [InlineData] became [Arguments], Dispose became an [After(Test)] hook, the swallowed try/catch became an awaited Throws, and all four tests and five runs survive:

{
 "task": "port",
 "summary": "CartTests is ported from xUnit 2.x to TUnit for .NET 9 with all 4 tests and 5 runs kept: [Fact]/[Theory] become [Test], [InlineData] becomes [Arguments], every test is async Task with awaited assertions, and Dispose becomes an [After(Test)] hook. On the way the swallowed try/catch with no assertion in Checkout_EmptyCart_Throws (lines 26-35) becomes an awaited Throws assertion and the blocking .Wait()/.Result calls (lines 30, 41) become awaits, as the TUnit move requires.",
 "file": {
  "name": "CartTests.cs",
  "code": "using System;\nusing System.Threading.Tasks;\nusing TUnit.Assertions;\nusing TUnit.Assertions.Extensions;\nusing TUnit.Core;\n\npublic class CartTests\n{\n    private readonly Cart _cart = new Cart(new FakePrices());\n\n    [Test]\n    public async Task Add_NewItem_IncreasesCount()\n    {\n        _cart.Add(\"apple\", 2);\n\n        await Assert.That(_cart.Count).IsEqualTo(2);\n    }\n..."
 },
 "map": [
  {
   "from": "using Xunit;",
   "to": "using TUnit.Core; using TUnit.Assertions; using TUnit.Assertions.Extensions;"
  },
  {
   "from": "[Fact] public void ...",
   "to": "[Test] public async Task ..."
  },
  {
   "from": "[Theory]",
   "to": "[Test]"
  },
  {
   "from": "[InlineData(\"apple\", 1, 0.5)] / [InlineData(\"pear\", 3, 2.1)]",
   "to": "[Arguments(\"apple\", 1, 0.5)] / [Arguments(\"pear\", 3, 2.1)]"
  },
  {
   "from": "Assert.True(_cart.Count == 2);",
   "to": "await Assert.That(_cart.Count).IsEqualTo(2);"
  },
  {
   "from": "Assert.Equal(expected, _cart.Total, 2);",
   "to": "await Assert.That(_cart.Total).IsEqualTo(expected).Within(0.005);"
  },
  {
   "from": "try { _cart.CheckoutAsync().Wait(); } catch (Exception) { }",
   "to": "await Assert.That(() => _cart.CheckoutAsync()).Throws<Exception>();"
  },
  {
   "from": "var receipt = _cart.CheckoutAsync().Result;",
   "to": "var receipt = await _cart.CheckoutAsync();"
  },
  {
   "from": "Assert.True(receipt.At <= DateTime.Now);",
   "to": "await Assert.That(receipt.At).IsGreaterThanOrEqualTo(before).And.IsLessThanOrEqualTo(after ..."
  },
  {
   "from": "class CartTests : IDisposable / public void Dispose() => _cart.Clear();",
   "to": "[After(Test)] public void Cleanup() => _cart.Clear();"
  }
 ],
 "packages": [
  "TUnit"
 ],
 "notes": [
  "Remove xunit, xunit.runner.visualstudio and Microsoft.NET.Test.Sdk from the project: TUnit runs on ...",
  "TUnit runs tests in parallel by default (xUnit 2.x runs tests within one class serially). Each test ...",
  "Every TUnit assertion is awaited; a non-awaited Assert.That(...) never evaluates and the test ...",
  "xUnit's Assert.Equal(expected, actual, 2) rounds both values to 2 decimals; Within(0.005) checks an ...",
  "TUnit's Throws<Exception>() accepts derived exception types (ThrowsExactly<T>() does not); the old ...",
  "[After(Test)] replaces Dispose(); TUnit would also call IDisposable.Dispose, but the hook is the ..."
 ],
 "caveats": [
  "Cart, FakePrices and the receipt type are not in the pasted code; Count is assumed to be an int ...",
  "The exception CheckoutAsync throws for an empty cart is unknown, so Throws<Exception>() is used ...",
  "If Total is decimal, change the expected column and Within tolerance to decimal (decimal cannot ...",
  "Nothing was compiled or run; run `dotnet test` on net9.0 after the switch."
 ]
}

Build the input yourself

The scanner and the reply checker are plain scripts that run under node (global.window = global; require("./cstscan.js"); require("./csreply.js")), then:

const S = CsTestScan, R = CsTestReply;
const st = { code: fs.readFileSync("OrderServiceTests.cs", "utf8"), target: "", focus: "" };
const scan = S.scan(st);                        // framework, className, tests, runs, counts, verdict, findings
const b = S.buildInput(st, "review");           // or "port" with st.target = "tunit"; clips long files and writes the facts
const wire = S.toWire(b.input);                 // every field a string - POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://dotnet-test-clinic.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"dotnet-test-clinic"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save one of the worked-example inputs above as input.json to follow along.

# input.json: the run input object - {"task": "review", "code": "using System; ...", "framework": "mstest", "target": "", "facts": "Framework: MSTest ...", "focus": ""}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses dotnet-test-clinic:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a port of the same class are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. A review carries verdict, summary, findings, prescan, rewrite, kept and caveats; a port carries summary, file, map, packages, notes and caveats. The code below prints the headline parts of either: for a review the verdict, the summary and each finding's severity and title; for a port the summary, the file name and the packages.

KEY="dotnet-test-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "port"
  then .summary, "file: \(.file.name)", "packages: \(.packages | join(", "))"
  else "verdict: \(.verdict)", .summary, (.findings[] | "[\(.severity)] \(.title)") end' reply.json

Step 6 — POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: dotnet-test-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json