Testing charts
Charts are easy to test: give the session a VirtualClock, send events, move time, and check the
configuration and the data. Nothing waits for real time, and the same test always gives the same
result. The examples use bun test; any test runner works.
A chart#
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { createSession, VirtualClock } from "@tinyactors/scxmljs";
import { Window } from "happy-dom";
const { DOMParser } = new Window();
const source = await readFile("traffic-light.scxml", "utf8");
async function start() {
const clock = new VirtualClock();
const session = await createSession(source, { clock, domParser: new DOMParser() });
session.start();
return { clock, session };
}
test("cycles red → green → yellow → red and counts the cycles", async () => {
const { clock, session } = await start();
expect(session.activeStateIds()).toEqual(["on", "red"]);
clock.advance(3000);
expect(session.isActive("green")).toBe(true);
clock.advance(4000);
expect(session.isActive("red")).toBe(true);
expect(session.snapshot()).toEqual({ cycles: 1 });
session.dispose();
});
test("power.off ends the chart at any time, with the count as donedata", async () => {
const { clock, session } = await start();
clock.advance(3500); // in green
session.send("power.off");
clock.run();
expect(session.status).toBe("done");
expect(await session.done).toEqual({ cycles: 0 });
session.dispose();
});What the clock does:
clock.run()runs everything that's queued now (such as events you sent), then every timer, in time order, until nothing is left. Charts whose timers re-arm themselves never run out, so give those a limit:clock.run(until)stops at the absolute timeuntil.clock.advance(ms)moves time forward byms, running everything that falls due on the way.clock.runNext()runs exactly one task, for checking what happens in between.clock.now(),clock.pendingandclock.nextTimerAtshow where things stand.
With a VirtualClock, send() only queues the event: it's processed at the next run(),
advance() or runNext().
session.snapshot() returns the values of every <data> element as plain data, from either data
model. Create a fresh session per test; always dispose() it (a sandboxed session holds a QuickJS
context).
Waiting for a state#
session.waitFor() resolves when a state is active, or all of several are, or a predicate holds.
It's convenient when the test doesn't control time, for example with real I/O:
await session.waitFor("saved", { timeoutMs: 5000 }); // rejects with a TimeoutError, or when the chart ends first
await session.waitFor(["playing", "muted"]); // all of them
await session.waitFor((s) => s.isActive("done") || s.isActive("failed"));timeoutMs is measured on the session's clock. session.steps() is an async iterator over
macrosteps, for asserting on the whole sequence:
const seen: string[][] = [];
for await (const step of session.steps()) seen.push(step.configuration.map((s) => s.id));Both data models#
The two entry points behave the same, and your tests can check that your chart does too: run the same test against both.
import { describe } from "bun:test";
import * as sandboxed from "@tinyactors/scxmljs";
import * as trusted from "@tinyactors/scxmljs/trusted";
for (const [name, { createSession }] of [["sandboxed", sandboxed], ["trusted", trusted]] as const) {
describe(name, () => {
// …the tests above, using this createSession
});
}UI that drives a chart#
Page code that uses bind() or connect() can be tested in happy-dom, with no browser:
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { bind, createSession } from "@tinyactors/scxmljs";
import { Window } from "happy-dom";
test("the login form signs in", async () => {
const window = new Window();
const document = window.document;
document.body.innerHTML = `
<form data-scxml-send="login"><input name="user" value="ada"><button>Log in</button></form>
<button id="logout" data-scxml-send="logout">Log out</button>`;
const session = await createSession(await readFile("login.scxml", "utf8"), { domParser: new window.DOMParser() });
session.start();
bind(session, document as unknown as Document, { reflectEnabled: true });
const logout = document.querySelector("#logout")!;
expect(logout.hasAttribute("data-scxml-enabled")).toBe(false);
document.querySelector("button")!.click(); // submits the form
await session.settled();
expect(session.isActive("signed-in")).toBe(true);
expect(session.snapshot()).toEqual({ user: "ada" });
expect(logout.hasAttribute("data-scxml-enabled")).toBe(true);
session.dispose();
});(The cast is needed because happy-dom's types differ slightly from the DOM's.)
The elements#
<scxml-view> and <scxml-explorer> work in happy-dom too. A custom element can only be
registered once per process, so install happy-dom's globals (window, document,
HTMLElement, customElements, CSSStyleSheet…) once, before importing the element module, and
keep all tests for one element in one file. For layout and visual checks, use a real browser.