Documentation
Browse documentation

DocumentationTools

Testing a widget

A PlayerVox OverCrow widget is tested in two ways, both without a desktop, a game or an account:

  • Unit tests of the logic, in Node.js, with @overcrow/sdk/testing: fast, for the functions of logic.ts.
  • Scenarios, played by overcrow-widget test in PlayerVox OverCrow's headless runtime: the real VM, style, layout and renderer, OverCrow's own checks, simulated services, virtual time and reference images. What passes there is what the overlay shows.
JSONtemplates/counter/tests/example.scenario.json
{
  "scenarioVersion": 1,
  "name": "example",
  "description": "The counter starts at 0 and Increase adds one. `overcrow-widget test` plays it and compares the images with tests/reference/example/; `--update` records them again (https://overcrow.playervox.com/docs/en/testing/).",
  "host": { "mode": "interactive" },
  "steps": [
    { "expect": { "state": "running", "text": ["0"], "image": "initial" } },
    { "pointer": { "click": { "label": "Increase" } } },
    { "expect": { "text": ["1"], "noText": ["0"], "image": "increased", "fault": "none" } }
  ]
}

This scenario starts the counter in Interactive mode, checks that it shows 0 and compares its image with a reference, clicks the button whose label is "Increase", then checks the new text and image.

Unit tests

@overcrow/sdk/testing comes with the SDK: npm install in a project made by overcrow-widget init, otherwise npm install --save-dev --save-exact @overcrow/sdk@1.0.0.

installRuntime() puts a stand-in for PlayerVox OverCrow in place before the logic is imported: service calls return promises that your test settles, subscriptions take the values you push, timers fire when you advance the time, and Date.now() is virtual.

JSwidgets/clock/tests/logic.test.mjs
const START = Date.UTC(2026, 6, 17, 12, 8, 42, 250);
const vm = installRuntime({
  now: START,
  host: {
    region: { numberFormat: "us", dateOrder: "mdy", offsetMinutes: 120 },
    messages: { label: "Local time {time}", "label-date": "Local time {time}, {date}" },
  },
});
const logic = await loadLogic();
const { state } = await import("@overcrow/sdk");
JSwidgets/clock/tests/logic.test.mjs
test("one timer at the next local minute, then each minute", () => {
  assert.equal(vm.timers.length, 1);
  assert.equal(vm.timers[0].dueAt, Date.UTC(2026, 6, 17, 12, 9));
  vm.advance(Date.UTC(2026, 6, 17, 12, 9) - vm.now);
  assert.equal(logic.clockTime(state.now, state.seconds), "14:09");
  assert.deepEqual(
    vm.timers.map(({ intervalMs, repeat }) => ({ intervalMs, repeat })),
    [{ intervalMs: 60_000, repeat: true }],
  );
  assert.equal(vm.advance(120_000), 2);
  assert.equal(logic.clockTime(state.now, state.seconds), "14:11");
});
MemberDoes
installRuntime({ now, host })Installs the stand-in; Date.now() returns the virtual time. One at a time.
now, advance(ms)The virtual time; advance fires the timers that fall due, in order (a repeating one re-arms), and returns their count.
setHost(changes)Changes host as PlayerVox OverCrow does: region, locale, options…
calls, lastCall(service)Every service call, with resolve(value) and reject(code).
push(service, value), fail(service, code)The next update, or the final failure, of the subscriptions to a service.
menu(row)Chooses an action row of the options menu.
draws, logs, timers, tableWhat the logic sent: canvas drawings, logs, timers, and the functions of the view.
uninstall()Removes the stand-in and gives Date.now back.

The stand-in runs your logic only: it does not apply PlayerVox OverCrow's rules (the grants, the accounts, the user-action rule) and renders nothing. Scenarios do. The reference widgets compile their logic.ts with a small loader before importing it:

JSwidgets/clock/tests/load-logic.mjs
export async function loadLogic() {
  const root = new URL("../", import.meta.url);
  const source = readFileSync(new URL("logic.ts", root), "utf8");
  const { outputText } = ts.transpileModule(source, {
    compilerOptions: {
      module: ts.ModuleKind.ES2022,
      target: ts.ScriptTarget.ES2023,
      verbatimModuleSyntax: true,
    },
  });
  mkdirSync(new URL("dist/", root), { recursive: true });
  // One file per test process: test files run in parallel.
  const output = new URL(`dist/logic-${process.pid}.mjs`, root);
  writeFileSync(output, outputText);
  return import(output.href);
}

Scenarios

A scenario is a JSON file of the project, tests/<name>.scenario.json, whose name is <name>. Its reference images are tests/reference/<name>/<image>.png. Neither is packaged.

SH
overcrow-widget test
overcrow-widget test --scenario example
overcrow-widget test --update

--update records the captured images as the references: look at them before you commit them. A failed image writes tests/output/<name>/<image>.actual.png and, for a difference, <image>.diff.png, with the differing pixels in red.

A scenario has four parts: host (the user's settings), fixtures (what the services answer), assets (images for the fixtures) and steps (what happens, and what must hold). The format is strict: an unknown field, name or value is an error of the scenario, reported with its path (steps[3].pointer.click) before anything runs. It is also checked against the manifest: the grants the widget declares, the menu rows that exist, the services of its capabilities.

host

Everything is optional.

MemberDefaultMeaning
localeenen or fr.
themedarkdark or light.
scale1000Content scale in thousandths: 500 to 1750 (50 % to 175 %).
sizethe manifest's preferred size{ width, height } of the frame, in logical pixels; the manifest's fit still applies.
modepassiveinteractive gives input to the widget.
visibletrueHidden, the widget gets no timer tick.
framefalsetrue captures PlayerVox OverCrow's frame around the content.
backgroundthe theme's panel colour#rrggbb behind the widget.
regionus, date order of the locale{ numberFormat, dateOrder }.
zoneUTC{ offsetMinutes, transitions: [{ at, offsetMinutes }] }: the user's UTC offset, and the instants at which it changes.
startAt2026-01-01T00:00:00ZThe virtual time at which the widget starts, in Unix milliseconds.
optionsthe rows' defaultsValues of the rows of the options menu.
grants"declared"Or the exact list of what the user granted (storage, network, fps.read…): PlayerVox OverCrow refuses the rest.
accountsconnected{ playervox, twitch }, each connected, disconnected, pending, expired or offline.
featuresallThe data sources of the machine, for requires.
storageemptyThe widget's storage before it starts.
JSONwidgets/clock/tests/summer-time.scenario.json
  "host": {
    "zone": {
      "offsetMinutes": 60,
      "transitions": [
        {
          "at": 1774746000000,
          "offsetMinutes": 120
        },
        {
          "at": 1792890000000,
          "offsetMinutes": 60
        }
      ]
    },
    "startAt": 1774745970000
  },

With an account that is not connected, PlayerVox OverCrow draws its own account panel over the widget, as it does for the user; capture it with frame.

fixtures

Fixtures replace where the data comes from, never PlayerVox OverCrow's checks. A call first passes the grants, its parameters, the user-action rule and the account check, exactly as in the overlay; only then does a fixture answer it. Every value must have the shape of the service's result: one that does not is an error of the scenario, not of the widget.

MemberAnswers
subscriptionsThe value of a subscription when it starts; publish steps send the next ones.
callsThe calls of a service, in order: { "value": … } or { "error": code }. A call beyond them is an error of the scenario.
httpThe http.fetch requests, each used once: { "request": { method, url }, "response": { status, contentType, body } } (or bodyBase64), or { "request": …, "error": code }. The manifest's network rules apply first, and the response bounds apply as in the overlay.
confirmationsaccept or cancel, in order, for PlayerVox OverCrow's confirmation of notes.delete and journal.delete.
intentsThe answer to each submitted write intent, in order: "accepted", { "error": code } or "pending".
JSONwidgets/stopwatch/tests/errors.scenario.json
  "fixtures": {
    "calls": {
      "stopwatch.toggle": [
        {
          "error": "unavailable"
        },
        {
          "value": {
            "running": true,
            "elapsedMs": 5000,
            "at": 0,
            "shortcuts": {
              "toggle": "Super+Alt+T",
              "reset": "Super+Alt+Z",
              "bound": true
            }
          }
        }
      ],
      "stopwatch.reset": [
        {
          "error": "stale_context"
        }
      ]
    },
    "subscriptions": {
      "stopwatch.subscribe": {
        "running": false,
        "elapsedMs": 5000,
        "at": 0,
        "shortcuts": {
          "toggle": "Super+Alt+T",
          "reset": "Super+Alt+Z",
          "bound": true
        }
      }
    }
  },
JSONwidgets/warframe-market/tests/errors.scenario.json
    "http": [
      {
        "request": {
          "method": "GET",
          "url": "https://api.warframe.market/v2/versions"
        },
        "error": "timeout"
      },
      {
        "request": {
          "method": "GET",
          "url": "https://api.warframe.market/v2/orders/item/primed_flow"
        },
        "error": "transport_failed"
      },
      {
        "request": {
          "method": "GET",
          "url": "https://api.warframe.market/v2/orders/item/primed_flow"
        },
        "response": {
          "status": 200,
          "contentType": "application/json",
          "body": "{\"apiVersion\":\"0.25.0\",\"data\":[],\"error\":null}"
        }
      }
    ]

The codes that PlayerVox OverCrow gives itself (permission_denied, gesture_required, not_connected, invalid_request) cannot be written in a fixture: get them the real way, with grants, accounts, a call outside a user action, or wrong parameters. Storage, timers and the clipboard are OverCrow's own and need no fixture.

assets

Images that a fixture hands to the widget, as PlayerVox OverCrow hands it a media cover or a chat emote: { "<name>": "<path>" }, at most 8, each a PNG or JPEG of the project (tests/assets/cover.png). Where a result holds an image handle, the fixture writes "fixture:<name>".

steps

StepDoes
{ "advance": { "ms": N } }Moves the virtual time by N ms. Every timer due on the way fires in order, and the UTC offset changes at its instant.
{ "advance": { "ms": N, "jump": true } }Moves the time at once, as a machine waking from sleep: each due timer fires once, late.
{ "host": { … } }Changes locale, theme, scale, size, mode, visible, region, zone or accounts.
{ "pointer": { "click": target } }A click. Also rightClick, move (hover), wheel ({ target, dx, dy }) and "leave".
{ "key": { "key": "Enter", "modifiers": ["shift"] } }A key press: Tab, Escape, ArrowUp, letters, digits…
{ "text": "…" }Text typed into the focused field.
{ "menu": { "id": "row", "value": … } }A row of the options menu: the value of a toggle, slider or choice, none for an action.
{ "publish": { "service": "fps.subscribe", "value": … } }The next value of a subscription.
{ "publish": { "service": "media.subscribe", "error": "unavailable" } }Ends the subscription with this failure.
{ "expect": { … } }What must hold once the previous steps settled.

A pointer target is { "at": [x, y] } (logical pixels of the content), { "text": "…" } (the first element showing exactly this text) or { "label": "…" } (its accessible label). Input reaches the widget in Interactive mode only. A click is a user action; a menu row never is.

Each step settles before the next: the widget has answered everything it was sent and nothing is in flight. There is no real waiting.

expect

MemberHolds when
imageThe capture matches tests/reference/<scenario>/<image>.png. Names are unique in a scenario.
text, noTextEach text is (is not) the whole text of an element the widget shows.
callsExactly these service calls were made since the previous calls expectation, in order: { service, params?, outcome? }, outcome being ok or the error code the widget received.
statestarting, running, restarting, failed, refused or stopped.
faultThe last fault of the widget's VM, or none.
clipboardThe last text the widget wrote to the clipboard.
intentsExactly these write intents were submitted since the previous intents expectation, in order: { intent, fields?, outcome? }. fields are the exact values PlayerVox OverCrow's form sent.
JSONtemplates/chart/tests/example.scenario.json
  "steps": [
    { "expect": { "state": "running", "text": ["Activity", "50"], "image": "initial" } },
    { "advance": { "ms": 10000 } },
    { "expect": { "noText": ["50"], "image": "ten-seconds", "fault": "none" } }
  ]

The faults a widget's VM can report:

CategoryFatalMeaning
resource_limityesHeap, stack, job queue or message budget exhausted inside the VM.
unresponsiveyesThe turn budget interrupted the VM.
protocol_violationyesThe host sent a message the VM cannot accept.
invalid_bundleyeslogic.js or the compiled view failed to load or register.
handler_exceptionnoAn event handler threw; the VM keeps running.

Reference images

An image is the widget's content, rendered at the scenario's scale, theme and size. Record the states that matter in both themes, both languages and two scales, as the reference widgets do: a scenario can change theme, locale and scale between two expect steps.

JSONtemplates/list/tests/example.scenario.json
  "steps": [
    { "expect": { "state": "running", "text": ["Today", "2 left"], "image": "initial" } },
    { "host": { "locale": "fr", "theme": "light" } },
    { "expect": { "image": "french-light", "fault": "none" } }
  ]

Images are compared with a small tolerance: a pixel differs when one of its channels differs by more than 2 levels, and the image differs when more than 100 pixels per million do. One wrong letter is far above that. A reference is never rewritten by a test run: only --update records images.

Time and determinism

The runtime is deterministic: the same package and scenario render the same bytes, on Linux and on Windows.

  • Time is virtual: Date.now() and new Date() return the scenario's time, timers fire when the scenario moves it.
  • Math.random() is seeded with a fixed value.
  • Text uses the fonts bundled with PlayerVox OverCrow, never the system's.
  • Rendering does not depend on the graphics card.

This holds for the test runtime only: in the overlay, time and randomness are real.

The headless runtime

overcrow-widget-headless is PlayerVox OverCrow's own code without a window: it validates the package as the overlay does and runs the widget's logic in the same sandbox. overcrow-widget test runs the version this CLI pins, 0.7.3-beta.1, and prints its version and SHA-256. Without the operating system's sandbox the runtime refuses to run and says so; the test command then ends with status 2.

The first time test needs the runtime, it downloads it: from the creator tools ZIP of your platform in the PlayerVox OverCrow release that published it, it keeps only the runtime, and only once its SHA-256 is the one this CLI pins. The ZIP is not kept. The runtime then stays in the CLI's cache:

PlatformPath
Linux~/.cache/overcrow-widget/runtime/0.7.3-beta.1/ ($XDG_CACHE_HOME instead of ~/.cache when set)
Windows%LOCALAPPDATA%\overcrow-widget\runtime\0.7.3-beta.1\

To work offline, put the runtime of the creator tools ZIP there yourself (executable on Linux): test finds it and downloads nothing. With --offline, test never downloads. A proxy set in HTTPS_PROXY is used.

The CLI checks its SHA-256 before every run. When a download fails, nothing is kept and test says to try again or to pass --runtime. --runtime <path> runs another runtime instead, such as one built from PlayerVox OverCrow; the output then says it is not the pinned one.