view .md
Guides · Migrate

Test a migrated app three ways

The scary part of a migration is not generating the new project — it is knowing whether the result actually behaves before you point anything real at it. HS-X makes the freshly-migrated dupe invocable through the production runtime router on your machine, from whichever client you think in: your terminal, your coding agent, or a bare HTTP request.

Outcome
Every migrated capability invoked and verified locally — by you, by an agent, and by curl — with zero deploys.
Prerequisites
  • A migrated project (hs-x migrate run — see Migrate an app)
  • bun install run inside the generated project

TL;DRhs-x dev invoke <capability-id> runs one capability through the production router with fixture defaults; --object/--input/--install override them, --last replays the previous dispatch. Agents drive the same engine from their shell with --json, and read the results back through the HS-X MCP server’s hsx.dev.logs and hsx.check. A running hs-x dev server exposes it to any HTTP client at POST /_hsx/invoke/<capability-id>.

One engine, three doors

hs-x migrate run leaves you with a net-new project — card-called legacy serverless functions as card backends, other portable functions as tools, webhook subscriptions as triggers, and cards carried over with their React source. The question that matters next is whether each one does what the legacy version did.

The answer mechanism is deliberately boring. There is exactly one invocation engine, and it is not a test double: your worker modules are loaded as-is, and the dispatch goes through the same runtime router a deployed Worker serves — the same payload validation, the same handler context, the same result envelope. The three doors into that engine only differ in who is knocking:

  • The terminal, for you: hs-x dev invoke.
  • The agent, for your coding agent: the same command with --json, plus the HS-X MCP server’s hsx.check and hsx.dev.logs around it.
  • HTTP, for scripts and CI: POST /_hsx/invoke/<id> on the dev server.

Because the engine is the production path, a green invocation here means the handler, its types, and its envelope survive contact with the runtime — not that they passed a simulation of it.

The card-to-backend seam is wired

A migrated serverless function is still a scaffold, not a completed body port. hs-x migrate run reports the remaining TODO(migration) markers. Shared helpers from the legacy function lib/ directory are preserved under src/workers/legacy/lib/; update imports to that path as you port each body.

For project cards, migration classifies each statically named runServerlessFunction call as a worker.cardBackend, infers its input fields from the frontend parameters object and legacy context.parameters reads, and generates _hsx-migration-run-serverless.ts. The card’s provider line is changed to pass that compatibility function down, so existing hooks remain untouched:

const result = await runServerlessFunction({
  name: 'get-orders',
  parameters: { contactId: context.crm.objectId },
});

The shim posts { input: parameters } through hubspot.fetch to /_hsx/cards/<name> and translates the runtime envelope back to the legacy { status, message, response } contract. At generation time, the Worker origin is a visible placeholder in both the shim and permittedUrls.fetch; hs-x deploy replaces it with the resolved workers.dev origin before HubSpot upload. A project-card.serverless-call.routed finding confirms the calls that were connected. If migration cannot recognize the provider shape safely, it leaves the source unchanged and emits project-card.serverless-call.rewire for that card.

Paste or port the original function body into the generated card backend, change legacy context.parameters.foo reads to input.foo, and then exercise the complete card-to-worker request in hs-x dev. A green structural hs-x check proves the scaffold and linkage are valid; the body TODOs still mark the work required for behavioral parity.

The terminal

From the migrated project root (or anywhere, with --cwd):

hs-x dev invoke score-record \
  --object '{"id":"d1","objectType":"deals","properties":{"amount":"50000"}}' \
  --input '{"threshold":25000}'

Fixture defaults fill anything you do not pass — a bare hs-x dev invoke score-record works seconds after migration. The output is the runtime’s own envelope, pretty-printed; exit code 0 means the dispatch and the handler both succeeded, so it slots straight into a shell loop or a pre-deploy check.

When an invocation fails, fix the handler and re-run the exact same dispatch:

hs-x dev invoke --last

Every invocation records its capability id and full payload to .hs-x/last-invocation.json; --last replays it, and explicit flags override individual parts. Typos get caught with the full menu: an unknown capability id error lists every id the project actually declares.

The agent

A coding agent uses the same terminal door, in a shape it can parse. hs-x dev invoke <capability-id> --json emits one structured result, and --last replays the recorded dispatch after an edit; an unknown capability id comes back with the full list of ids the project declares, so the agent never guesses. The HS-X MCP server (@hs-x/mcp) wraps the rest of the loop as tools:

  • hsx.check — validate the project; the same diagnostics hs-x check prints, as structured findings.
  • hsx.dev.logs — read what the handler logged during the last invocations.
  • hsx.dev.session.status and hsx.dev.session.stop — see whether a dev session is running, and end it.
  • hsx.docs.search and hsx.docs.fetch — look up the SDK surface while porting a body.

Invocation itself is not an MCP tool (the MCP server deliberately refuses hsx.dev.invoke); the agent shells out to hs-x dev invoke --json or POSTs to the HTTP door below. That is a complete debug cycle: invoke, read the envelope, read the logs, edit the handler, replay.

Raw HTTP

Start the dev server and every capability gets an HTTP door:

hs-x dev --port 8787
curl -s -X POST http://127.0.0.1:8787/_hsx/invoke/score-record \
  -H 'content-type: application/json' \
  -d '{"input":{"threshold":25000}}'

The body takes the same optional input / enrolledObject / install keys; the response wraps the runtime envelope with the worker and kind that served it, plus a logs array of what the handler logged. This is the door for CI smoke jobs, REST clients, and anything else that speaks HTTP and nothing else.

What fixtures cover, and what they do not

Fixture invocation proves the handler logic, the capability wiring, and the runtime contract. Two things it deliberately does not fake:

  • HubSpot API calls. A handler that uses ctx.hubspot needs a connected portal install to authenticate as — the same requirement production has. Run the full hs-x dev session with a connected account for that leg.
  • Pre-cutover decisions. Migration findings like a legacy webhook receiver URL or an endpoint function’s public contract are listed by hs-x migrate run and stay visible in the generated handler stubs. Testing does not make those decisions for you; it tells you the code around them holds.