Skip to content
Mercury
English
Design Book
Menu

The tools, from app to package

Create an app, check each change, run the tests and prepare the package.

This lesson follows a source build at 0e08e7af, reporting mx 0.1.1. Its publication is pending; check the installation guide before choosing a public download. The commands cover the work from a new app to a verified package archive.

Start an app

mx new starts with the versions in your module cache. The installer fills that cache when it installs a build supporting this route. In a Unix shell, the equivalent manual first fill is:

MXNOSUMDB='registry.npmjs.org/fiberfx/*' mx mod cache
mx new hello
cd hello
mx dev

The setting applies to that one command. Until a signed checksum database is available, it allows the first fill to accept Mercury packages from the proxy and record their checksums. Later installs verify the recorded bytes. An unconfigured first fill refuses new bytes.

Read the cache output, including its TypeScript compiler line. A compiler download can fail separately from the module fill.

mx new hello writes package.json, mx.mod, mx.sum, index.html and src/main.tsx, then installs from cache. With the modules present, creating and building the app works offline. A missing cache or nonempty target directory is refused with exit 1; a missing name is usage error 2. Existing files are preserved.

The app uses the cached Hyperscript and UI versions. The measured source build writes mx 0.1.1 in the new module file. It does not create tsconfig.json; add one before typechecking.

Check and format code

Start with checks that leave your source files alone:

mx lint
mx fmt --check

mx lint uses Oxc and the project's .oxlintrc.json. Without a configuration, correctness rules are warnings. A warning is reported; an error makes the command exit 1.

mx fmt --check lists files that need formatting and exits 1 when it finds any. To write the formatting changes, run mx fmt. The formatter checks that a second pass is stable.

mx lint --fix applies safe fixes and checks again. It can still exit 1 when an error has no safe fix: a redundant rename can be removed while a debugger statement remains for you to resolve. --fix-unsafe also permits fixes that can change behavior; review the resulting diff.

In the separate TypeScript project from the next section, run the three stages together:

mx quality
mx quality --json

Quality runs typecheck, lint and formatting checks even when an earlier stage fails. It returns the worst stage's exit code: 0 passed, 1 findings, 2 usage, 3 unavailable or unable to run. mx quality --fix applies lint fixes and formats the files.

For automation, a quality verb's --json returns a stage object with stage, outcome, detail, files, fixed and findings. mx quality --json returns a list of those objects. Read every stage; an unavailable stage has not passed.

Type-check a project

Use a separate project with no dependencies for this exercise. Create a directory named course-typecheck with the following four files.

package.json:

{"name": "course-typecheck", "type": "module"}

mx.mod:

module registry.npmjs.org/course-typecheck

mx 0.1.1

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

src/main.ts:

export const value: number = 1;

From that project's directory, run:

mx typecheck
mx typecheck --json
mx quality --json

The measured typecheck prints typecheck: 0 errors, 0 warnings in 1 file and exits 0. The combined quality command reports all three stages passed.

Now add src/type-error.ts with export const x: number = "s";. A second check reports TS2322 at that file's first line and exits 1. Remove the deliberately invalid file to restore the clean project. Typechecking itself leaves project files unchanged.

The default configuration is the root's tsconfig.json. Pass a configuration path to check another project, for example mx typecheck tsconfig.json, or use --root to select its directory. A source-file path is not a project configuration.

This source revision uses the native TypeScript 7.0.2 compiler. mx verifies its downloaded archive against a pinned SHA-256 and keeps it in the module cache. mx mod cache can fetch it ahead of time; the first check can fetch it when network access is allowed.

mx runs a temporary configuration and does not write generated files into the project. With MXPROXY=off and no cached compiler, the result is unavailable, exit 3. Fill the compiler cache with network access before relying on offline checks. A cached archive with the wrong checksum is refused.

Check the generated app

Return to the hello app created in the first section. Create tsconfig.json in its root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "jsxImportSource": "@fiberfx/hyperscript",
    "strict": true,
    "skipLibCheck": true
  },
  "include": [
    "src"
  ]
}

Run mx typecheck from hello. With the cached Hyperscript and UI 4.1.1 packages used in this exercise, the measured result is typecheck: 0 errors, 0 warnings in 1 file, exit 0. The configuration keeps strict checking enabled; mx resolves the packages' declarations and supplies types for the CSS imports it serves.

To see a real failure, add src/planted.ts containing export const x: number = "s"; and check again. Expect TS2322 at line 1, column 14 and exit 1. Remove only that exercise file and repeat the check: it returns to exit 0. Typechecking leaves the project files unchanged. The compiler cache rules above apply to this app too.

mx test

usage: mx test [<path>...] [--root <dir>] [--timeout <seconds>] [--filter <words>]

mx test runs a module's tests in a headless browser that mx starts itself, from the same graph mx dev serves. Nothing is assumed passed: each result has to come back to mx, and a run that reports no tests, loses a report or never ends fails.

  • Which files. Every *.test.* or *.spec.* file with a js, mjs, jsx, ts, mts or tsx extension under the root. node_modules, dist, target and coverage are never searched. With none: mx test: no tests under <root>, exit 1.
  • Imports first. A test file whose imports can't be resolved fails by name before the browser starts, FAIL <url> > (its imports): <why>, and the other files still run.
  • The browser. MX_BROWSER names one. Otherwise mx takes the newest Chromium, then Playwright's headless shell, from PLAYWRIGHT_BROWSERS_PATH, ~/Library/Caches/ms-playwright or ~/.cache/ms-playwright.
  • --timeout bounds the whole run, by default 120 s: the run didn't end within <n> s. Separately, a page that stays silent for 30 s past every test's own timeout is reported as stalled, and the rest of its file doesn't run.
  • --filter <words> keeps the tests whose <file> > <name> contains the words, and counts the rest as left out.
Exit Meaning
0 every test passed
1 a test failed, none ran, or the run broke
2 usage
3 could not measure: no browser was found

Exit 3 is never a pass. mx prints where it looked, then Set MX_BROWSER to a Chromium binary.

What a test file imports

A test file imports from vitest, and the page's import map answers that with mx's own runtime, not Vitest. It has:

  • describe, it and test, and the hooks beforeAll, afterAll, beforeEach and afterEach;
  • expect, with its matchers;
  • vi: vi.fn, vi.spyOn, vi.stubGlobal, fake timers (vi.useFakeTimers, vi.advanceTimersByTime, vi.runAllTimers and the rest) and vi.setSystemTime;
  • expectTypeOf and assertType, which assert nothing at run time, because types are the compiler's business;
  • visit, which is mx's own.

As in Vitest, a test has 5 s and a hook 10 s. vi.mock is not there: calling it throws vi.mock is not in mx test: a module is mocked by import map, later (mercury.mx.13).

Driving a page with visit

async function visit(url, { width = 0, height = 0, scale = 1, mobile = false, scheme = null, ready = null } = {})

visit opens a page at an http origin in the run's own browser, over the DevTools protocol, emulated as asked: a viewport, a scale, mobile, and scheme as prefers-color-scheme. It resolves once ready, a CSS selector, matches, or without one once the page has loaded. A width or height left out keeps the browser's own.

The handle it returns has two methods:

  • evaluate(fn, ...args) runs fn in that page and resolves with its JSON value, or rejects with what it threw;
  • close() closes the page.

A visit left open is closed when its test file ends. If the browser's DevTools fail, mx ends that file by name.

Reading a run

One line per test, then the count:

ok    <file> > <name>
skip  <file> > <name>
FAIL  <file> > <name>: <message>
mx test: <passed> passed, <failed> failed, of <total>

A failure's stack is read back to the line as written, through the compiler's source map. The run checks itself as well:

  • when the pages' count and the reports that arrived differ, it says so (the pages counted N tests and M reports arrived) and fails;
  • with --filter it prints how many were left out;
  • with every test filtered away, it prints no tests ran: the filter matched none and exits 1.

mx lsap

usage: mx lsap [--mcp] | mx lsap --version

mx lsap starts the Hyperscript language server, hyperscript-lsap, which must be installed in the same directory as mx. mx starts no other server and never falls back. It gives the server mx's own path in HYPERSCRIPT_LSAP_MX, so the server resolves imports with this mx, and then exits with the server's exit code. In a session the server's stdout is the protocol, so mx writes nothing there itself.

A release built by release.sh carries the server: hyperscript-lsap-<target> sits beside mx-<target>, and you install it beside mx, in the same directory, as hyperscript-lsap. Windows is the exception. The server is Unix-only, so a Windows release ships mx alone, and mx lsap there refuses with The language server isn't built for Windows in this release.

  • --mcp is passed on to the server unchanged.
  • --version prints both versions. The verified 0.1.1 archive prints the two versions separately:
mx 0.1.1
hyperscript-lsap 0.1.0

With no server beside mx, it refuses with exit 1: Error: mx lsap found no language server beside mx., where it looked, and Put hyperscript-lsap in <dir>: a release of mx ships it there.

The server is Rust. Today it is an imported TypeScript language server, built under Mercury's rules, and it doesn't answer Mercury's own facts yet.

mx run

mx run has two forms, and the directory decides which.

A task. When the package.json in the directory has a script by that name, mx run <name> runs it as a cached task. The key is a sha256 over:

  • the script, its arguments and the module's place in the workspace;
  • NODE_ENV, plus the names MXRUN_ENV lists;
  • the sha256 of every input file, and the tools.

On a hit, mx checks every stored blob, restores the outputs and replays the log: mx run: <member> <task>: hit <key>, restored N files. On a miss it names the first difference: mx run: <member> <task>: miss (<why>). Only exit 0 is stored. In this source revision, MXRUN_CACHE selects the task store; otherwise it is $XDG_CACHE_HOME/mx/run, or ~/.cache/mx/run when XDG_CACHE_HOME is unset.

MXCACHE selects the Mercury module cache, normally ~/.mercury/modules. It is separate from cached task results. A repeated mx run report can hit its task cache without changing the module cache.

The flags go before the script's name:

Flag What it does
--no-cache run the script, neither reading nor storing an entry
--verify on a hit, run the task again and hold its outputs to the stored ones
-r, --recursive the task in every workspace member
--filter <member> the task in the named members, by directory or package name, and every member they need
-j <n> parallel tasks, by default the CPUs, at most 4

-r and --filter need an mx.work: mx run: -r and --filter need an mx.work at or above <dir>. A script that is mx test is never cached, because mx test reads the whole workspace from source: mx run: <member> <task>: uncached: mx test reads the workspace from source.

A host. Any other name keeps the host form: mx run <command> [args] builds the host (MX3), then becomes the command, so its signals and exit code are the command's. mx run mix phx.server is the example in mx's source.

With no name at all, mx run answers mx run: a command is required.

Prepare a package archive

From a built package directory, run:

mx pack --npm

mx writes the npm-compatible tarball under .mx/pack/. It follows package.json's files list, required package files and entry points; publishConfig.exports replaces the development export map in the packed manifest. The same inputs produce the same archive bytes and SHA-512 integrity.

Packing checks a fresh consumer outside the workspace: peers install, exports resolve, and reachable module imports and declaration-file imports resolve. Missing files, stale build output or a failed consumer check refuse the pack. An archive whose consumer check fails is removed.

Build the package again when a refusal reports stale output. A successful pack prepares a verified artifact; publishing it is a separate action. mx pack --npm does not publish to npm.

A package can use "pack": "mx pack --npm" in its scripts and call mx run pack. A cache hit restores the tarball and reruns the consumer check.