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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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 "". |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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.
| Field | Required | What |
|---|---|---|
task | yes | review or port. The model answers that task and only that task. |
code | yes | the 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. |
framework | yes | the framework the page detected: mstest, xunit, nunit or tunit. A review rewrites the class in this framework; a port starts from it. |
target | no (may be "") | for port, the framework to port to - one of the four, and not the same as framework. Empty ("") for review. |
facts | yes | the 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. |
focus | no | what 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_note | no | present 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):
| Rule | Severity | Area | Title |
|---|---|---|---|
CT-ASYNCVOID | error | Async and timing | async void test |
CT-ARITY | error | Data-driven | Data row does not match the parameters |
CT-NOASSERT | warn | Assertions | Test with no assertion |
CT-SLEEP | warn | Async and timing | Thread.Sleep / fixed delay in a test |
CT-RESULT | warn | Async and timing | Blocking on .Result / .Wait() |
CT-TRYCATCH | warn | Assertions | Exception swallowed in a test |
CT-DUPLICATE | warn | Data-driven | Near-duplicate tests |
CT-NONDET | info | Isolation | Clock, random or new Guid in a test |
CT-CONSOLE | info | Isolation | Console output in a test |
CT-NAMING | info | Conventions | Test names not MethodName_Scenario_ExpectedBehavior |
MS-EXPECTEDEX | warn | Assertions | [ExpectedException] |
MS-ARGORDER | warn | Assertions | Assert.AreEqual(actual, expected) |
MS-ISTRUEEQ | warn | Assertions | Assert.IsTrue(a == b) |
MS-SEALED | info | Conventions | Test class not sealed |
MS-DATATESTMETHOD | info | Conventions | [DataTestMethod] |
MS-TESTINIT | info | Conventions | [TestInitialize] that only sets fields |
MS-NULLCTX | info | Conventions | Nullable or null! TestContext |
MS-SINGLE | info | Assertions | .Single() in a test |
XU-EQTRUE | warn | Assertions | Assert.True(a == b) |
XU-FACTPARAMS | error | Data-driven | [Fact] with parameters |
XU-THEORYNODATA | error | Data-driven | [Theory] with no data |
XU-SETUPATTR | error | Setup and lifecycle | Setup attribute xUnit ignores |
NU-CLASSIC | warn | Assertions | NUnit classic assert |
NU-EXPECTEDEX | error | Assertions | [ExpectedException] in NUnit |
TU-NOAWAIT | error | Async and timing | TUnit assertion not awaited |
TU-SYNCTEST | error | Async and timing | TUnit test that is not async Task |
TU-FOREIGN | error | Setup and lifecycle | xUnit/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:
- Every framework: Arrange-Act-Assert; names
MethodName_Scenario_ExpectedBehavior; one behaviour per test; tests independent and idempotent; assertions that state intent and report both values; exceptions asserted with the framework's Throws API, never swallowed in atry/catch; near-identical tests folded into one data-driven test; async tests returnTaskand await (neverasync void, never.Result/.Wait()); noThread.Sleep; dependencies mocked through interfaces; data rows match the parameters one to one. - MSTest:
[TestClass]on a sealed class;[TestMethod](not[DataTestMethod]) with[DataRow]or[DynamicData]; the constructor for synchronous setup,[TestInitialize]only for async setup;Assert.AreEqual(expected, actual)with expected first;Assert.Throws<T>/ThrowsExactly<T>/ThrowsAsync<T>instead of[ExpectedException];Assert.ContainsSingle,Assert.HasCountand friends instead of.Single()or hard casts. - xUnit: no class attribute;
[Fact]for single cases,[Theory]with[InlineData]/[MemberData]/[ClassData]; the constructor for setup,IDisposableorIAsyncLifetimefor teardown;IClassFixture<T>for shared context;Assert.Equal,Assert.Throws<T>/await Assert.ThrowsAsync<T>;ITestOutputHelperfor output. - NUnit:
[TestFixture]and[Test];[TestCase],[TestCaseSource],[Values];[SetUp]/[TearDown]; the constraint modelAssert.That(actual, Is.EqualTo(expected))(NUnit 4 moved the classic asserts toClassicAssert);Assert.Throws<T>. - TUnit (.NET 8+): no class attribute;
[Test]onasync Taskmethods;[Arguments],[MethodDataSource];[Before(Test)]/[After(Test)]; every assertion awaited -await Assert.That(actual).IsEqualTo(expected),await Assert.That(() => ...).Throws<T>(); tests run in parallel by default ([NotInParallel]to opt out). - A port maps each construct to its counterpart - class attribute, test attribute, inline data, data source, setup, cleanup, equality, Throws, categories and output - and every test and every data row survives it.
House rules
- Every statement is grounded in the pasted class; no production type, member, mock or behaviour is invented. What the answer depends on but could not see (the class under test, its constructor, a fake) goes in
caveats. - In a review, every
errorandwarnfinding in the facts is addressed by its rule id; a finding the model thinks is wrong is disputed with a reason, never silently dropped. - The class name, its namespace and every test name stay. A test folded into a data-driven test keeps its name as the new method's name or as a display name, and its values as a data row. The page counts tests and runs (a data row is a run) before and after.
- A port fixes the scan's
errorfindings on the way: it never carries anasync voidor a broken data row into the new framework. - No claim that anything was built, compiled or run - "run
dotnet test", not "these pass". - Plain text in strings; code goes in the code fields as plain C# with
\nnewlines and no Markdown fences. The reply is ONE JSON object and nothing else.
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.
summary- two or three sentences: what the class tests, the verdict and the first thing to fix.findings- most severe first:rule(the facts' rule id, orNEW),severity(high,mediumorlow),title,where(such asline 66or a test name),why(one or two sentences about this class),fix(the corrected lines).prescan- one entry pererrorandwarnrule id in the facts:rule,call(agreeordisagree),note(why, one sentence).rewrite-nameandcode: the whole class in the same framework, including what did not change.kept- anything deliberately left as it was:rule,what,why.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.
summary- two sentences.file-nameandcode: the whole class in the target framework, with the target'susingdirectives.map- one entry per construct changed:fromandto(such as[DataRow(1, 10)]->[InlineData(1, 10)]).packages- the NuGet packages the test project needs for the target (such asxunit,xunit.runner.visualstudio,Microsoft.NET.Test.Sdk).notes- behaviour differences the developer must know (setup order, parallelism, shared state, async assertions).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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://dotnet-test-clinic.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// dotnet-test-clinic.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://dotnet-test-clinic.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://dotnet-test-clinic.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// DotnetTestClinic.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class DotnetTestClinic {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://dotnet-test-clinic.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = +"YOUR_TOKEN" # from https://dotnet-test-clinic.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://dotnet-test-clinic.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://dotnet-test-clinic.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "dotnet-test-clinic"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "dotnet-test-clinic" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "dotnet-test-clinic"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "dotnet-test-clinic"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "dotnet-test-clinic" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "dotnet-test-clinic"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "dotnet-test-clinic" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"task": "review", "code": "using System; ...", "framework": "mstest", "target": "", "facts": "Framework: MSTest ...", "focus": ""}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "port", code, framework, target, facts, focus} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "dotnet-test-clinic:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "port":
print(reply["summary"])
print("file:", reply.get("file", {}).get("name"))
print("packages:", ", ".join(reply.get("packages", [])))
else:
print("verdict:", reply.get("verdict"))
print(reply.get("summary"))
for f in reply.get("findings", []):
print(f"[{f['severity']}] {f['title']}")
import { createHash } from "node:crypto";
const key = `dotnet-test-clinic:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated);
if (reply.task === "port") {
console.log(reply.summary);
console.log("file:", reply.file?.name);
console.log("packages:", (reply.packages || []).join(", "));
} else {
console.log("verdict:", reply.verdict);
console.log(reply.summary);
for (const f of reply.findings || []) console.log(`[${f.severity}] ${f.title}`);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "dotnet-test-clinic:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
if reply["task"] == "port" {
fmt.Println(reply["summary"])
file, _ := reply["file"].(map[string]any)
fmt.Println("file:", file["name"])
packages, _ := reply["packages"].([]any)
fmt.Println("packages:", packages)
} else {
fmt.Println("verdict:", reply["verdict"])
fmt.Println(reply["summary"])
findings, _ := reply["findings"].([]any)
for _, x := range findings {
f := x.(map[string]any)
fmt.Printf("[%v] %v\n", f["severity"], f["title"])
}
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "dotnet-test-clinic:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("port".equals(reply.path("task").asText())) {
System.out.println(reply.path("summary").asText());
System.out.println("file: " + reply.path("file").path("name").asText());
System.out.println("packages: " + reply.path("packages"));
} else {
System.out.println("verdict: " + reply.path("verdict").asText());
System.out.println(reply.path("summary").asText());
for (JsonNode f : reply.path("findings")) System.out.println("[" + f.path("severity").asText() + "] " + f.path("title").asText());
}
require "digest"
key = "dotnet-test-clinic:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "port"
puts reply["summary"], "file: #{(reply["file"] || {})["name"]}", "packages: #{(reply["packages"] || []).join(", ")}"
else
puts "verdict: #{reply["verdict"]}", reply["summary"]
(reply["findings"] || []).each { |f| puts "[#{f["severity"]}] #{f["title"]}" }
end
$key = "dotnet-test-clinic:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "port") {
echo $reply["summary"], "\n";
echo "file: ", $reply["file"]["name"] ?? "", "\n";
echo "packages: ", implode(", ", $reply["packages"] ?? []), "\n";
} else {
echo "verdict: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["findings"] ?? [] as $f) echo "[", $f["severity"], "] ", $f["title"], "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"dotnet-test-clinic:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
if ((string?)reply["task"] == "port") {
Console.WriteLine(reply["summary"]);
Console.WriteLine($"file: {reply["file"]?["name"]}");
Console.WriteLine($"packages: {reply["packages"]?.ToJsonString()}");
} else {
Console.WriteLine($"verdict: {reply["verdict"]}");
Console.WriteLine(reply["summary"]);
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"[{f!["severity"]}] {f["title"]}");
}
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
rev = json.load(open("input.json")) # the same run input as input.json (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "dotnet-test-clinic:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `dotnet-test-clinic:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "dotnet-test-clinic:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "dotnet-test-clinic:" + M.readTree(rev).path("task").asText() + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "dotnet-test-clinic:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$task = json_decode($rev, true)["task"];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: dotnet-test-clinic:$task:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"dotnet-test-clinic:{JsonNode.Parse(rev)!["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);