Run RTL Upgrade Desk from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page masks the test file, matches every describe/it/test, lists every Enzyme call with its RTL counterpart, finds the react-dom/test-utils APIs React 19 removes and StrictMode counts, and moves act and Simulate itself, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the upgraded file with the same scanner (rtlreply.js).
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":"rtl-upgrade-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, unit, code, facts) is missing. The page always sends all five of task, unit, code, carry, facts; carry is an empty string for review. |
| 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, chosen by task, in the order the work runs - inspect, then produce:
review- each of the page's findings confirmed (with the issue, the fix and a short code fix) or cleared (with why), up to five issues a scan cannot see on real lines, and the fixes in the order to make them, each naming its ids.rewrite- the whole test file on render, screen and userEvent, every Enzyme and test-utils import gone, each test asserting what the user sees - with what changed and what was kept, every test title unchanged.
A missing or unknown task is answered as review. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.
The input
| Field | Required | What |
|---|---|---|
task | yes | review or rewrite. If it is missing or neither, do review and set "task": "review". |
unit | yes | the file's path and the target (React 18 or React 19). |
code | yes | the test file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send. |
carry | send it (empty for review) | rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty. |
facts | yes | the page's scan: one finding per line as id | area | severity | line | where | evidence | source, every Enzyme call with the test it sits in and the page's RTL starting point, and the list of tests. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skills, @github/react18-enzyme-to-rtl, @github/react19-test-patterns - the only standard the run applies:
- Enzyme tests implementation; RTL tests behaviour. Never translate Enzyme 1:1 - a test that read wrapper.state() or called wrapper.instance() is rewritten to assert the visible outcome (getByText('Count: 3'), toBeDisabled()) or to trigger the method through the UI. If no UI trigger exists, say whether the internal method should be tested directly at all (usually not).
- The rewrite shape: render(<Component />) (replacing shallow and mount - no wrapper variable, query through screen), a screen query, an interaction (const user = userEvent.setup(); await user.click / user.type - fireEvent only when a specific event property matters), and an assertion on what the user sees. Tests with interactions are async.
- Query priority: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, and getByTestId last. getBy throws when missing (positive assertions), queryBy returns null (absence: expect(queryBy...).not.toBeInTheDocument()), findBy waits (async elements). getAllByRole for several.
- Delete Enzyme.configure and the adapter import - RTL needs no setup; jest-dom matchers (toBeInTheDocument, toBeDisabled, toHaveTextContent, toHaveAttribute, toHaveClass) come from the test setup file. wrapper.update() is not needed; async changes are awaited with findBy or waitFor. wrapper.setProps becomes rerender. Providers wrap the element passed to render (or the project's customRender).
- React 19 test order: 1. act from 'react' (import { act } from 'react'); 2. Simulate becomes fireEvent from @testing-library/react with the same event and init; 3. the other react-dom/test-utils APIs map to RTL (renderIntoDocument to render, findRendered.../scryRendered... to role queries, isElement and friends removed); 4. StrictMode call counts - effect spies counted twice under React 18 StrictMode run once in React 19, while render-body calls stay doubled; measure the received count by running the test, never guess; 5. async act wrapping for remaining "not wrapped in act" warnings.
House rules
- Use only the input. The component under test is not pasted: never invent the text, label or role it renders. Take the visible text and labels from the test itself (an asserted string, a field name, a button the test clicks); when an assertion needs something the test does not show, use the most likely role or label from the selector, mark it in a caveat as one the team checks against the component, and never pretend it is known.
- The page's ids and lines are measured facts: never renumber an id.
- No web links (http or https) in prose.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks. Name calls plainly (screen.getByRole('button'), userEvent.setup()). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt.
- Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
review
Confirm or clear each of the page's findings, decide what every Enzyme test should assert instead, add what a scan cannot see, and put the steps in order.
summary- one sentence, at most 40 words: whether the tests run on React 18 / 19 today, what the page already moved, and the first step.findings- one object per page finding you confirm, in id order:id(the page's id),widget(the test, e.g. "it - submits the credentials"),issue(one sentence: what breaks, or what the test proves that a user never sees),fix(one sentence: the RTL replacement or the visible outcome to assert),code(the migrated lines for that piece only, at most 6; "" when fix says it all). A finding that groups one Enzyme API across several tests names the tests in widget and shows one representative migration in code - the rewrite applies it everywhere.cleared- one object per page finding that is not a problem here, withidandwhy(one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.extra- at most 5 issues the scan cannot see, each tied to a line of the code:id(X1, X2, ...),area(enzyme, behaviour, test-utils, strictmode or queries),line(a number),widget,issue,fix. Examples: an assertion that only checks a mock was called and nothing the user sees, an async interaction not awaited, a getBy used to assert absence, a provider the component needs that the test does not render, a loading state the old test skipped with update().[]when there are none.plan- 2 to 10 short strings, the steps in order (the React 19 test order first - act, Simulate, other test-utils - then Enzyme setup and rendering, then each test's queries, interactions and assertions, then StrictMode counts), each naming the ids it covers (e.g. "Render with RTL and query by label (E3, E5)"). Every confirmed id appears in a step.[]only when nothing is confirmed.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "review",
"summary": "one sentence",
"findings": [ { "id": "E1", "widget": "the file", "issue": "one sentence", "fix": "one sentence", "code": "import { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';" } ],
"cleared": [ { "id": "E9", "why": "one sentence" } ],
"extra": [ { "id": "X1", "area": "behaviour", "line": 16, "widget": "it - submits the credentials", "issue": "one sentence", "fix": "one sentence" } ],
"plan": [ "Render with RTL and query by label (E3, E5)" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in rtlreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.
rewrite
Rewrite the whole test file, migrated.
summary- one sentence, at most 40 words: what the migration changed and what it left for the team.code- the complete migrated file. Keep every describe, it and test with its title word for word, the component imports and every mock; replace Enzyme and react-dom/test-utils completely - no import from enzyme, its adapter or react-dom/test-utils may remain - with render, screen and userEvent (or fireEvent) from Testing Library, each test asserting what the user sees. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. shallow / mount - render(...), queries through screen; providers wrap the element. find(selector) - the query the facts suggest, in RTL's priority order; text() - getByText or toHaveTextContent. simulate(event) - await user.click / user.type / user.keyboard, the test made async. prop(...) - the jest-dom matcher for the visible attribute; state(...) and setState(...) - the visible outcome, reached through the UI; instance().method() - the UI action that runs it. update() - removed (await findBy or waitFor where the change is async); setProps - rerender. Enzyme.configure and the adapter - deleted. act, Simulate - as the page moved them, and drop any import (act included) that the migration leaves unused; StrictMode counts - the count the carry decided, or unchanged with a caveat to measure, since only a test run can tell.changes- one object per page finding you acted on, in id order:id,change(one sentence: what changed, naming the test). Extra ids (X1, ...) from the carried review may appear too.kept- one object per page finding you did not change:id,why(one sentence - the review cleared it, or it needs a run or a file the paste does not hold). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "rewrite",
"summary": "one sentence",
"code": "the complete migrated test file",
"changes": [ { "id": "E1", "change": "one sentence" } ],
"kept": [ { "id": "E9", "why": "one sentence" } ],
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every test title survives; the file is not shortened (at least 70% of its lines) and elides nothing; and the rewritten code is scanned again - a rule the changes claim fixed that still fires, or a rule the paste never had, is flagged. A test title the rewrite drops or renames is flagged.
Worked examples
Three runs from the page's free samples (invented apps): the review of an Enzyme suite that tests state and instances, the rewrite of the same file carrying that review, and the review of an RTL test still on react-dom/test-utils. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
review - An Enzyme suite that tests state and instances
The page read it as: Will not run - 1 blocker, 4 major, 7 minor; findings E1 enzyme-import, E2 enzyme-configure, E3 enzyme-render-shallow, E4 enzyme-interact-simulate, E5 enzyme-query-find, E6 enzyme-internals-state, E7 enzyme-render-mount, E8 enzyme-internals-instance, E9 enzyme-async-update, E10 enzyme-assert-text, E11 enzyme-internals-setState, E12 enzyme-assert-prop.
Input (facts is 5036 characters; the first 1394 are shown):
{
"task": "review",
"unit": "File: src/LoginForm.test.jsx\nTarget: React 19",
"code": "import React from \"react\";\nimport Enzyme, { shallow, mount } from \"enzyme\";\nimport Adapter from \"enzyme-adapter-react-16\";\nimport LoginForm from \"./LoginForm\";\n\nEnzyme.configure({ adapter: new Adapter() });\n\ndescribe(\"LoginForm\", () => {\n it(\"submits the credentials\", () => {\n const onSubmit = jest.fn();\n const wrapper = shallow(<LoginForm onSubmit={onSubmit} />);\n wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n wrapper.find('input[name=\"password\"]').simulate(\"change\", { target: { value: \"hunter22\" } });\n wrapper.find(\"button[type='submit']\").simulate(\"click\");\n expect(wrapper.state(\"loading\")).toBe(true);\n expect(onSubmit).toHaveBeenCalledWith({ email: \"a@example.com\", password: \"hunter22\" });\n });\n\n it(\"shows the server error\", () => {\n const wrapper = mount(<LoginForm onSubmit={jest.fn()} />);\n wrapper.instance().handleError(\"Wrong password\");\n wrapper.update();\n expect(wrapper.find(\".error\").text()).toBe(\"Wrong password\");\n });\n\n it(\"disables the button while loading\", () => {\n const wrapper = shallow(<LoginForm onSubmit={jest.fn()} />);\n wrapper.setState({ loading: true });\n expect(wrapper.find(\"button\").prop(\"disabled\")).toBe(true);\n });\n});\n",
"carry": "",
"facts": "The page's scan of 32 lines - 3 tests, 17 Enzyme calls (3 reading internals), 0 react-dom/test-utils APIs (0 moved by the page); verdict: Will not run.\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | enzyme | blocker | line 2 | the file | The file imports Enzyme (enzyme, enzyme-adapter-react-16) - Enzyme has no React 18 adapter and no React 18 support path, so every test in it must be rewritten with React Testing Library. | source: import Enzyme, { shallow, mount } from \"enzyme\";\n- E2 | enzyme | major | line 6 | the file | Enzyme.configure with an adapter - RTL needs no setup. | source: Enzyme.configure({ adapter: new Adapter() });\n- E3 | enzyme | minor | line 11 | 2 calls | shallow() on 2 lines (11, 27) - an Enzyme API with no React 18 support. | source: const wrapper = shallow(<LoginForm onSubmit={onSubmit} />);\n- E4 | enzyme | minor | line 12 | 3 calls | wrapper.simulate() on 3 lines (12, 13, 14) - an Enzyme API with no React 18 support. | source: wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n- E5 | enzyme | minor | line 12 | 5 calls | wrapper.find() on 5 lines (12, 13, 14, 23, 29) - an Enzyme API with no React 18 support. | source: wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n- E6 | behaviour | major | line 15 | 1 call | wrapper.state() on 1 line (15) - it\n\n[... 3642 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "The file will not run on React 19 because Enzyme has no adapter for it; the page moved nothing, so the first step is replacing the Enzyme imports and setup with Testing Library.",
"findings": [
{
"id": "E1",
"widget": "the file",
"issue": "The enzyme and enzyme-adapter-react-16 imports cannot load under React 18 or 19, so no test in the file runs.",
"fix": "Import render and screen from @testing-library/react and userEvent from @testing-library/user-event instead.",
"code": "import { render, screen } from \"@testing-library/react\";\nimport userEvent from \"@testing-library/user-event\";"
},
{
"id": "E2",
"widget": "the file",
"issue": "Enzyme.configure with the React 16 adapter is setup that only Enzyme needs.",
"fix": "Delete the Enzyme.configure call together with the Adapter import.",
"code": ""
},
{
"id": "E3",
"widget": "it - submits the credentials",
"issue": "shallow() on lines 11 and 27 renders one level deep through an API with no React 18 support.",
"fix": "Replace both shallow calls with render(<LoginForm ... />) and query through screen with no wrapper variable.",
"code": "render(<LoginForm onSubmit={onSubmit} />);"
},
{
"id": "E4",
"widget": "it - submits the credentials",
"issue": "simulate('change') and simulate('click') call the handlers directly instead of typing and clicking the way the user does.",
"fix": "Use const user = userEvent.setup() with await user.type and await user.click, and make the test async.",
"code": "const user = userEvent.setup();\nawait user.type(screen.getByLabelText(/email/i), \"a@example.com\");\nawait user.type(screen.getByLabelText(/password/i), \"hunter22\");\nawait user.click(screen.getByRole(\"button\"));"
},
{
"id": "E5",
"widget": "it - submits the credentials",
"issue": "find() selects by name attribute, button type, tag and the .error class, none of which the user sees.",
"fix": "Query the fields by label, the button by role and the error by its text.",
"code": "screen.getByLabelText(/email/i);\nscreen.getByLabelText(/password/i);\nscreen.getByRole(\"button\");"
},
{
"id": "E6",
"widget": "it - submits the credentials",
"issue": "wrapper.state('loading') proves an internal flag that the user never sees.",
"fix": "Assert the visible loading outcome instead, the submit button being disabled, as the third test shows loading does.",
"code": "expect(screen.getByRole(\"button\")).toBeDisabled();"
},
{
"id": "E7",
"widget": "it - shows the server error",
"issue": "mount() is an Enzyme API with no React 18 support.",
"fix": "Replace mount with render, which already renders the full tree.",
"code": "render(<LoginForm onSubmit={onSubmit} />);"
},
{
"id": "E8",
"widget": "it - shows the server error",
"issue": "wrapper.instance().handleError() calls an internal method that RTL cannot reach and that a user only reaches by a failed submit.",
"fix": "Make onSubmit reject with the message, submit the form through the UI, and leave handleError untested directly.",
"code": "const onSubmit = jest.fn().mockRejectedValue(new Error(\"Wrong password\"));\nrender(<LoginForm onSubmit={onSubmit} />);\nawait user.click(screen.getByRole(\"button\"));"
},
{
"id": "E9",
"widget": "it - shows the server error",
"issue": "wrapper.update() has no RTL counterpart and hides that the error arrives asynchronously.",
"fix": "Remove it and wait for the error with screen.findByText.",
"code": ""
},
{
"id": "E10",
"widget": "it - shows the server error",
"issue": "text() reads the .error node through Enzyme rather than checking what is on the screen.",
"fix": "Await screen.findByText('Wrong password') and assert it is in the document.",
"code": "expect(await screen.findByText(\"Wrong password\")).toBeInTheDocument();"
},
{
"id": "E11",
"widget": "it - disables the button while loading",
"issue": "wrapper.setState({ loading: true }) forces a state the user can only reach by submitting.",
"fix": "Pass an onSubmit that never settles and submit through the UI so the component enters loading itself.",
"code": "const onSubmit = jest.fn(() => new Promise(() => {}));\nrender(<LoginForm onSubmit={onSubmit} />);\nawait user.click(screen.getByRole(\"button\"));"
},
{
"id": "E12",
"widget": "it - disables the button while loading",
"issue": "prop('disabled') reads a React prop instead of the DOM state the user meets.",
"fix": "Assert the button with the jest-dom matcher toBeDisabled().",
"code": "expect(screen.getByRole(\"button\")).toBeDisabled();"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "behaviour",
"line": 20,
"widget": "it - shows the server error",
"issue": "The plain jest.fn() passed as onSubmit never fails, so nothing in the test connects a server error to the message shown.",
"fix": "Give this test an onSubmit that rejects with Wrong password and reach the error by submitting the form."
},
{
"id": "X2",
"area": "behaviour",
"line": 27,
"widget": "it - disables the button while loading",
"issue": "The render passes a plain jest.fn() that resolves at once, so a UI submit would leave loading too fast to observe.",
"fix": "Pass an onSubmit that returns a pending promise before clicking the button."
}
],
"plan": [
"Swap the Enzyme imports for Testing Library and userEvent (E1)",
"Delete Enzyme.configure and the adapter (E2)",
"Render with render() in place of shallow and mount (E3, E7)",
"Query fields by label, the button by role and the error by text (E5, E10)",
"Type and click with userEvent in async tests (E4)",
"Assert the disabled button instead of loading state (E6, E11, E12, X2)",
"Reach the error through a rejected onSubmit and await findByText (E8, E9, X1)"
],
"caveats": [
"The labels email and password and a single button are assumed from the selectors; check them against LoginForm.",
"Whether LoginForm shows the rejected error's message and disables the button while loading needs a check against the component.",
"If the form has more than one button, give getByRole('button') the submit button's accessible name."
]
}
What step 5 prints for it: E1: Import render and screen from @testing-library/react and userEvent from @testing-library/user-event instead. | E2: Delete the Enzyme.configure call together with the Adapter import. | E3: Replace both shallow calls with render(<LoginForm ... />) and query through screen with no wrapper variable. | E4: Use const user = userEvent.setup() with await user.type and await user.click, and make the test async. | E5: Query the fields by label, the button by role and the error by its text. | E6: Assert the visible loading outcome instead, the submit button being disabled, as the third test shows loading does. | E7: Replace mount with render, which already renders the full tree. | E8: Make onSubmit reject with the message, submit the form through the UI, and leave handleError untested directly. | E9: Remove it and wait for the error with screen.findByText. | E10: Await screen.findByText('Wrong password') and assert it is in the document. | E11: Pass an onSubmit that never settles and submit through the UI so the component enters loading itself. | E12: Assert the button with the jest-dom matcher toBeDisabled().
The page's check: kept - nothing to flag
rewrite - An Enzyme suite that tests state and instances
The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the rewritten code: 12 findings before, 0 after.
Input (facts is 5028 characters; the first 1394 are shown; carry is 1899 characters; the first 1398 are shown):
{
"task": "rewrite",
"unit": "File: src/LoginForm.test.jsx\nTarget: React 19",
"code": "import React from \"react\";\nimport Enzyme, { shallow, mount } from \"enzyme\";\nimport Adapter from \"enzyme-adapter-react-16\";\nimport LoginForm from \"./LoginForm\";\n\nEnzyme.configure({ adapter: new Adapter() });\n\ndescribe(\"LoginForm\", () => {\n it(\"submits the credentials\", () => {\n const onSubmit = jest.fn();\n const wrapper = shallow(<LoginForm onSubmit={onSubmit} />);\n wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n wrapper.find('input[name=\"password\"]').simulate(\"change\", { target: { value: \"hunter22\" } });\n wrapper.find(\"button[type='submit']\").simulate(\"click\");\n expect(wrapper.state(\"loading\")).toBe(true);\n expect(onSubmit).toHaveBeenCalledWith({ email: \"a@example.com\", password: \"hunter22\" });\n });\n\n it(\"shows the server error\", () => {\n const wrapper = mount(<LoginForm onSubmit={jest.fn()} />);\n wrapper.instance().handleError(\"Wrong password\");\n wrapper.update();\n expect(wrapper.find(\".error\").text()).toBe(\"Wrong password\");\n });\n\n it(\"disables the button while loading\", () => {\n const wrapper = shallow(<LoginForm onSubmit={jest.fn()} />);\n wrapper.setState({ loading: true });\n expect(wrapper.find(\"button\").prop(\"disabled\")).toBe(true);\n });\n});\n",
"carry": "E1 | confirmed | the file | Import render and screen from @testing-library/react and userEvent from @testing-library/user-event instead.\nE2 | confirmed | the file | Delete the Enzyme.configure call together with the Adapter import.\nE3 | confirmed | it - submits the credentials | Replace both shallow calls with render(<LoginForm ... />) and query through screen with no wrapper variable.\nE4 | confirmed | it - submits the credentials | Use const user = userEvent.setup() with await user.type and await user.click, and make the test async.\nE5 | confirmed | it - submits the credentials | Query the fields by label, the button by role and the error by its text.\nE6 | confirmed | it - submits the credentials | Assert the visible loading outcome instead, the submit button being disabled, as the third test shows loading does.\nE7 | confirmed | it - shows the server error | Replace mount with render, which already renders the full tree.\nE8 | confirmed | it - shows the server error | Make onSubmit reject with the message, submit the form through the UI, and leave handleError untested directly.\nE9 | confirmed | it - shows the server error | Remove it and wait for the error with screen.findByText.\nE10 | confirmed | it - shows the server error | Await screen.findByText('Wrong password') and assert it is in the document.\nE11 | confirmed | it - disables the button while loading | Pass an onSubmit\n\n[... 501 more characters cut from this display ...]",
"facts": "The page's scan of 32 lines - 3 tests, 17 Enzyme calls (3 reading internals), 0 react-dom/test-utils APIs (0 moved by the page); verdict: Will not run.\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | enzyme | blocker | line 2 | the file | The file imports Enzyme (enzyme, enzyme-adapter-react-16) - Enzyme has no React 18 adapter and no React 18 support path, so every test in it must be rewritten with React Testing Library. | source: import Enzyme, { shallow, mount } from \"enzyme\";\n- E2 | enzyme | major | line 6 | the file | Enzyme.configure with an adapter - RTL needs no setup. | source: Enzyme.configure({ adapter: new Adapter() });\n- E3 | enzyme | minor | line 11 | 2 calls | shallow() on 2 lines (11, 27) - an Enzyme API with no React 18 support. | source: const wrapper = shallow(<LoginForm onSubmit={onSubmit} />);\n- E4 | enzyme | minor | line 12 | 3 calls | wrapper.simulate() on 3 lines (12, 13, 14) - an Enzyme API with no React 18 support. | source: wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n- E5 | enzyme | minor | line 12 | 5 calls | wrapper.find() on 5 lines (12, 13, 14, 23, 29) - an Enzyme API with no React 18 support. | source: wrapper.find('input[name=\"email\"]').simulate(\"change\", { target: { value: \"a@example.com\" } });\n- E6 | behaviour | major | line 15 | 1 call | wrapper.state() on 1 line (15) - it\n\n[... 3634 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "rewrite",
"summary": "Moved all three LoginForm tests from Enzyme to Testing Library with userEvent and visible assertions; the team checks the assumed labels, the single button and that a rejected submit shows its message.",
"code": "import React from \"react\";\nimport { render, screen } from \"@testing-library/react\";\nimport userEvent from \"@testing-library/user-event\";\nimport LoginForm from \"./LoginForm\";\n\ndescribe(\"LoginForm\", () => {\n it(\"submits the credentials\", async () => {\n const user = userEvent.setup();\n const onSubmit = jest.fn(); [... 1297 more characters cut from this display]",
"changes": [
{
"id": "E1",
"change": "Replaced the enzyme and adapter imports with render and screen from @testing-library/react and userEvent."
},
{
"id": "E2",
"change": "Deleted Enzyme.configure and the Adapter import."
},
{
"id": "E3",
"change": "Replaced shallow with render in submits the credentials and disables the button while loading."
},
{
"id": "E4",
"change": "Turned the change and click simulations in submits the credentials into awaited user.type and user.click calls in an async test."
},
{
"id": "E5",
"change": "Replaced the find selectors with getByLabelText for the fields, getByRole for the button and findByText for the error."
},
{
"id": "E6",
"change": "Submits the credentials now asserts the button is disabled instead of reading the loading state."
},
{
"id": "E7",
"change": "Replaced mount with render in shows the server error."
},
{
"id": "E8",
"change": "Shows the server error now submits the form with an onSubmit that rejects instead of calling handleError on the instance."
},
{
"id": "E9",
"change": "Removed wrapper.update() from shows the server error; the error is awaited with findByText."
},
{
"id": "E10",
"change": "Shows the server error asserts the Wrong password text is in the document instead of reading text() from the .error node."
},
{
"id": "E11",
"change": "Disables the button while loading reaches loading by submitting with a pending onSubmit instead of setState."
},
{
"id": "E12",
"change": "Disables the button while loading asserts toBeDisabled() instead of reading the disabled prop."
},
{
"id": "X1",
"change": "Shows the server error renders with a rejecting onSubmit so the message comes from a failed submit."
},
{
"id": "X2",
"change": "Disables the button while loading passes an onSubmit that returns a promise that never settles."
}
],
"kept": [],
"caveats": [
"The email and password labels and a single button are assumed from the selectors; check them against LoginForm.",
"Shows the server error assumes LoginForm displays the rejected error's message; confirm against the component.",
"Submits the credentials asserts the disabled button right after the click, as the old loading check did; if onSubmit resolving ends loading at once, move that check to the pending-promise test only."
]
}
Cut from this display only (the saved reply and the check use the full text): code (1615 characters, 318 shown).
What step 5 prints for it: E1: Replaced the enzyme and adapter imports with render and screen from @testing-library/react and userEvent. | E2: Deleted Enzyme.configure and the Adapter import. | E3: Replaced shallow with render in submits the credentials and disables the button while loading. | E4: Turned the change and click simulations in submits the credentials into awaited user.type and user.click calls in an async test. | E5: Replaced the find selectors with getByLabelText for the fields, getByRole for the button and findByText for the error. | E6: Submits the credentials now asserts the button is disabled instead of reading the loading state. | E7: Replaced mount with render in shows the server error. | E8: Shows the server error now submits the form with an onSubmit that rejects instead of calling handleError on the instance. | E9: Removed wrapper.update() from shows the server error; the error is awaited with findByText. | E10: Shows the server error asserts the Wrong password text is in the document instead of reading text() from the .error node. | E11: Disables the button while loading reaches loading by submitting with a pending onSubmit instead of setState. | E12: Disables the button while loading asserts toBeDisabled() instead of reading the disabled prop. | X1: Shows the server error renders with a rejecting onSubmit so the message comes from a failed submit. | X2: Disables the button while loading passes an onSubmit that returns a promise that never settles.
The page's check: kept - nothing to flag
review - An RTL test still on react-dom/test-utils
The page read it as: Will not run - 2 blocker, 0 major, 1 minor; findings E1 test-utils-act, E2 test-utils-simulate, E3 strictmode-count.
Input:
{
"task": "review",
"unit": "File: src/Counter.test.jsx\nTarget: React 19",
"code": "import React, { StrictMode } from \"react\";\nimport { render, screen } from \"@testing-library/react\";\nimport { act, Simulate } from \"react-dom/test-utils\";\nimport Counter from \"./Counter\";\n\ndescribe(\"Counter\", () => {\n it(\"increments on click\", () => {\n render(<Counter />);\n act(() => {\n Simulate.click(screen.getByRole(\"button\", { name: /add/i }));\n });\n expect(screen.getByText(\"Count: 1\")).toBeTruthy();\n });\n\n it(\"reports the count once per change\", () => {\n const onChange = jest.fn();\n render(<StrictMode><Counter onChange={onChange} /></StrictMode>);\n expect(onChange).toHaveBeenCalledTimes(2);\n });\n});\n",
"carry": "",
"facts": "The page's scan of 21 lines - 2 tests, 0 Enzyme calls (0 reading internals), 2 react-dom/test-utils APIs (2 moved by the page); verdict: Will not run.\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | test-utils | blocker | line 3 | act | act is imported from react-dom/test-utils, which React 19 removes - it is the first fix, because it unblocks everything else. | source: import { act, Simulate } from \"react-dom/test-utils\";\n- E2 | test-utils | blocker | line 10 | Simulate | Simulate from react-dom/test-utils is removed in React 19. | source: Simulate.click(screen.getByRole(\"button\", { name: /add/i }));\n- E3 | strictmode | minor | line 18 | it - reports the count once per change | toHaveBeenCalledTimes(2) under StrictMode - React 19 no longer double-invokes effects in development, so an effect-call count halves (a render-body count stays doubled). | source: expect(onChange).toHaveBeenCalledTimes(2);\nEvery Enzyme call (line | test | call | the page's RTL starting point):\n- none\nTests: increments on click; reports the count once per change.\nmust: account for every finding id above exactly once - in findings (confirmed) or in cleared (with the reason)."
}
Reply:
{
"task": "review",
"summary": "The file will not run on React 19 because act and Simulate come from react-dom/test-utils; the page moved act to react and Simulate to fireEvent, so the first step is that import.",
"findings": [
{
"id": "E1",
"widget": "the file",
"issue": "React 19 removes react-dom/test-utils, so importing act from it stops the whole file from loading.",
"fix": "Import act from react, or drop it once the click goes through userEvent.",
"code": "import { act } from \"react\";"
},
{
"id": "E2",
"widget": "it - increments on click",
"issue": "Simulate.click no longer exists in React 19 and skips the real browser event path.",
"fix": "Click with await user.click from userEvent.setup() in an async test, or fireEvent.click as the page moved it.",
"code": "const user = userEvent.setup();\nawait user.click(screen.getByRole(\"button\", { name: /add/i }));\nexpect(screen.getByText(\"Count: 1\")).toBeInTheDocument();"
},
{
"id": "E3",
"widget": "it - reports the count once per change",
"issue": "toHaveBeenCalledTimes(2) counts a StrictMode double call that React 19 no longer makes if onChange runs from an effect.",
"fix": "Run the test on React 19 and set the count to the received number, likely 1 if onChange fires from an effect.",
"code": ""
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "queries",
"line": 12,
"widget": "it - increments on click",
"issue": "toBeTruthy on a getByText result proves nothing extra, since getByText already throws when the text is missing.",
"fix": "Use the jest-dom matcher toBeInTheDocument() on the query."
},
{
"id": "X2",
"area": "behaviour",
"line": 9,
"widget": "it - increments on click",
"issue": "The manual act wrapper around the click is only needed because Simulate bypasses Testing Library.",
"fix": "Remove the act wrapper once the click uses await user.click, which wraps in act itself."
}
],
"plan": [
"Move act off react-dom/test-utils (E1)",
"Replace Simulate.click with an awaited userEvent click and drop the act wrapper (E2, X2)",
"Assert Count: 1 with toBeInTheDocument (X1)",
"Run the StrictMode test on React 19 and set the measured count (E3)"
],
"caveats": [
"Whether onChange is called from an effect or the render body is not in the paste; only a run gives the right count."
]
}
What step 5 prints for it: E1: Import act from react, or drop it once the click goes through userEvent. | E2: Click with await user.click from userEvent.setup() in an async test, or fireEvent.click as the page moved it. | E3: Run the test on React 19 and set the count to the received number, likely 1 if onChange fires from an effect.
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./rtlkit.js"); require("./rtlreply.js")), then:
const K = RtlKit, R = RtlReply;
const f = K.form({ name: "src/Box.test.jsx", targets: "react19", carry: "",
code: "import { shallow } from \"enzyme\";\nit(\"opens\", () => {\n const w = shallow(<Box />);\n expect(w.state(\"open\")).toBe(false);\n});" });
// f.findings, f.scan.enzymeCalls (every call and its RTL starting point), f.scan.migrated, f.checklist
// K.findingsCsv(f), K.callsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review"); // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {status: kept or flags, notes}
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, 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://rtl-upgrade-desk.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://rtl-upgrade-desk.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"]
// rtl-upgrade-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://rtl-upgrade-desk.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://rtl-upgrade-desk.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
}
// RtlUpgradeDesk.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 RtlUpgradeDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://rtl-upgrade-desk.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://rtl-upgrade-desk.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://rtl-upgrade-desk.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://rtl-upgrade-desk.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":"rtl-upgrade-desk"}' | 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": "rtl-upgrade-desk"})["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: "rtl-upgrade-desk" })).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": "rtl-upgrade-desk"}, 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", "rtl-upgrade-desk"), 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: "rtl-upgrade-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "rtl-upgrade-desk"])["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 = "rtl-upgrade-desk" }))!["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.
# input.json: the run input object - {"task": "review", "unit": "File: src/LoginForm.test.jsx\nTarget: React 19", "code": "import React from \"react\";\nimport Enzyme, { shallow, mount } from \"enzyme\";\n...", "carry": "", "facts": "The page's scan of 32 lines - 3 tests, 17 Enzyme calls ...\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | enzyme | blocker | line 2 | ..."}
# - 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", "unit": "File: src/LoginForm.test.jsx\nTarget: React 19", "code": "import React from \"react\";\nimport Enzyme, { shallow, mount } from \"enzyme\";\n...", "carry": "", "facts": "The page's scan of 32 lines - 3 tests, 17 Enzyme calls ...\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | enzyme | blocker | line 2 | ..."}
# - 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 "rewrite", unit, code, carry, facts} (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 rtl-upgrade-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, findings, cleared, extra, plan and caveats; a rewrite reply carries summary, code, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.
KEY="rtl-upgrade-desk:$(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 == "rewrite" then (.changes[] | "\(.id): \(.change)")
else (.findings[] | "\(.id): \(.fix)") end' reply.json
import hashlib, time
key = "rtl-upgrade-desk:" + 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"), reply["task"])
if reply["task"] == "rewrite":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `rtl-upgrade-desk:${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, reply.task);
if (reply.task === "rewrite") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "rtl-upgrade-desk:" + 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"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "rewrite" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "rtl-upgrade-desk:" + 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") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("rewrite")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "rtl-upgrade-desk:#{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"], reply["task"]].inspect
if reply["task"] == "rewrite"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "rtl-upgrade-desk:" . $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"], " ", $reply["task"], "\n";
if ($reply["task"] === "rewrite") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"rtl-upgrade-desk:{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>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "rewrite") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: rtl-upgrade-desk:$(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: {"task": "review", "unit": "File: src/LoginForm.test.jsx\nTarget: React 19", "code": "import React from \"react\";\nimport Enzyme, { shallow, mount } from \"enzyme\";\n...", "carry": "", "facts": "The page's scan of 32 lines - 3 tests, 17 Enzyme calls ...\nFindings (id | area | severity | line | where | evidence | source):\n- E1 | enzyme | blocker | line 2 | ..."}
# - 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": "rtl-upgrade-desk:" + 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": `rtl-upgrade-desk:${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", "rtl-upgrade-desk:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
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", "rtl-upgrade-desk:" + input.get("task") + ":" + 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" => "rtl-upgrade-desk:#{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");
$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: rtl-upgrade-desk:" . json_decode($rev, true)["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", $"rtl-upgrade-desk:{input["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);