← RTL Upgrade Desk
API
Tokens

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.

StatusCodeMeaning
400validation_errorThe 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.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - inspect, then produce:

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

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
unityesthe file's path and the target (React 18 or React 19).
codeyesthe 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.
carrysend 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.
factsyesthe 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_notenooptional; 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:

House rules

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.

  1. 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.
  2. 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.
  3. cleared - one object per page finding that is not a problem here, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  4. 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.
  5. 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.
  6. 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.

  1. summary - one sentence, at most 40 words: what the migration changed and what it left for the team.
  2. 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.
  3. 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.
  4. 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.
  5. 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"}
}

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')

Step 3 — GET /me

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

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

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# 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}' 

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

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