Build apps
Testing
Test what your app draws, with a pinned clock and no surprises from the network.
On this page
A render is a function of its inputs: the settings in params, and the time, the display and
the platform's services in ctx. A test supplies all of them, so it can pin the clock to the
moment that matters, put the display in any time zone, and make every service the app should not
touch fail the test.
ledable developer init does not set up tests. The SDK exports what tests need, and this page
sets them up with Vitest, on the example below: an app that counts the days
left in the year, in the display's own time zone, and asks again at midnight.
import { defineApp, select } from "@ledable/sdk/app";
import { Frame, Rect, Text, renderFrame } from "@ledable/sdk/graphics";
import type { BitmapFont } from "@ledable/sdk/graphics";
import { MODERNDOS, TINY, loadFonts } from "@ledable/sdk/fonts";
import { zonedDate } from "@ledable/sdk/time";
import { yearProgress, type YearProgress } from "./year";
const FONTS = { big: MODERNDOS, small: TINY } as const;
type Fonts = { readonly [Key in keyof typeof FONTS]: BitmapFont };
const HOUR_MS = 60 * 60 * 1000;
const COLOR = "#f6cd6d";
function draw(fonts: Fonts, progress: YearProgress, show: string) {
const percent = `${Math.floor(progress.done * 100)}%`;
const days = progress.daysLeft === 1 ? "day left" : "days left";
const [big, caption] = show === "Per cent"
? [percent, `of ${progress.year}`]
: [String(progress.daysLeft), days];
const filled = Math.max(1, Math.round(60 * progress.done));
return renderFrame(
<Frame width={64} height={32} background="#000000">
<Text x={32} y={9} anchor="mm" text={big}
font={fonts.big} color={COLOR} />
<Text x={32} y={20} anchor="mm" text={caption}
font={fonts.small} color="#ffffff" />
<Rect x={2} y={27} width={60} height={3} fill="#333333" />
<Rect x={2} y={27} width={filled} height={3} fill={COLOR} />
</Frame>,
);
}
export default defineApp({
settings: () => ({
show: select({
name: "Show",
description: "The number in large type",
options: ["Days left", "Per cent"],
default: "Days left",
}),
}),
async render(ctx, params) {
const fonts = await loadFonts(ctx.assets, FONTS);
// The day as the display's own time zone has it, on the platform's
// clock: a display in Auckland starts the new year hours before
// one in London.
const local = zonedDate(ctx.now, ctx.timezone) ?? ctx.now;
const progress = yearProgress(local);
// Ask again at midnight, when the count changes. On a night the
// clocks change this is off by the hour they move, which a day
// count can afford.
const softTtlMs = progress.untilMidnightMs;
return {
image: [draw(fonts, progress, params.show)],
softTtlMs,
hardTtlMs: softTtlMs + HOUR_MS,
};
},
async preview(ctx) {
const fonts = await loadFonts(ctx.assets, FONTS);
const progress = {
year: 2026, daysLeft: 87, done: 0.76, untilMidnightMs: 0,
};
return { image: [draw(fonts, progress, "Days left")] };
},
});
Set up Vitest
Install it in the project:
npm install --save-dev vitestThen add vitest.config.ts. An app imports fonts and images as files, which only the CLI's build
knows how to turn into references; the SDK's @ledable/sdk/app/asset-files module has the same
rule for a test runner. It runs in Node.js only, so the app never imports it.
import { defineConfig } from "vitest/config";
import { assetModule, locateAsset } from "@ledable/sdk/app/asset-files";
export default defineConfig({
plugins: [
{
// An imported .webp or .ledfont file becomes the reference the
// CLI's build makes of it, so tests see what a bundle sees.
name: "ledable-assets",
enforce: "pre",
async load(id) {
if (!/\.(webp|ledfont)$/.test(id)) return null;
return assetModule({ path: (await locateAsset(id)).path });
},
},
],
test: {
// The SDK's fonts import .ledfont files, which only the plugin
// above turns into modules; Node would refuse them.
server: { deps: { inline: ["@ledable/sdk"] } },
},
});The CLI type-checks every TypeScript file in the project with the app's types only, which do not
include Node.js or Vitest, so a test file would stop every build. Leave tests and the Vitest
configuration out of it in tsconfig.json:
{
"extends": "./.ledable/tsconfig.json",
"include": [
"./**/*.ts",
"./**/*.tsx"
],
"exclude": [
"node_modules",
"dist",
".ledable",
"**/*.test.ts",
"vitest.config.ts"
]
}Vitest reads the JSX settings from tsconfig.json, which extends a file the CLI writes into
.ledable/. Run the CLI's check first, for example in a script in package.json:
{
"scripts": {
"test": "ledable developer check && vitest run"
}
}npm testTest a render with a pinned clock
Build the context by hand. RenderContext makes
TypeScript list every member; give the ones the app should not use a function that throws, so an
unexpected request, asset read or cache call fails the test instead of passing quietly.
/** A capability this app must not use: calling it fails the test. */
function unexpected(name: string) {
return async (): Promise<never> => {
throw new Error(`The app called ${name}`);
};
}
/** The project's asset files, read as the platform reads them. */
const assets: AppAssets = {
async bytes(ref) {
const file = await assetFile(import.meta.dirname, ref.path);
return new Uint8Array(await readFile(file));
},
image: unexpected("ctx.assets.image"),
frames: unexpected("ctx.assets.frames"),
};
/** A display in `timezone` asking at `now`. */
function context(now: string, timezone: string): RenderContext {
return {
now: new Date(now),
timezone,
language: "en",
model: "test",
resolution: [64, 32],
firmware: "test",
instanceId: "test",
cacheId: "",
durationMs: 5000,
secrets: {},
assets,
cache: { get: unexpected("ctx.cache.get") },
auth: { getToken: unexpected("ctx.auth.getToken") },
credentials: { get: unexpected("ctx.credentials.get") },
news: { getSummary: unexpected("ctx.news.getSummary") },
locations: { timezoneAt: unexpected("ctx.locations.timezoneAt") },
images: { decode: unexpected("ctx.images.decode") },
};
}assetFile maps an asset's path back to the file in the project, or in the installed SDK for one
of its fonts, so the test reads the same bytes the platform would.
With the clock in your hands, a test can stand anywhere in time. These tests put two displays at the same moment in different time zones, and check that a day draws the same pixels from morning to night:
describe("render", () => {
const params = { show: "Days left" };
it("counts in the display's own time zone", async () => {
const now = "2026-12-31T20:00:00Z";
const london = context(now, "Europe/London");
// Already 09:00 on 1 January in Auckland.
const auckland = context(now, "Pacific/Auckland");
const there = await app.render(auckland, params);
const here = await app.render(london, params);
expect(here.softTtlMs).toBe(4 * HOUR_MS);
expect(there.softTtlMs).toBe(15 * HOUR_MS);
expect(here.image).not.toEqual(there.image);
});
it("draws the same pixels all day", async () => {
const morning = context("2026-06-01T08:00:00Z", "Europe/London");
const evening = context("2026-06-01T22:00:00Z", "Europe/London");
const first = await app.render(morning, params);
const second = await app.render(evening, params);
expect(first.image).toEqual(second.image);
});
});render returns the frames as pixels, so a test compares them directly. A pure function that the
app uses, such as yearProgress here, is tested like any function.
Test through the platform's protocol
createAppServer and hostApp from @ledable/sdk/app are what the platform runs your app with:
they read the settings from a display's request, check them against what settings returns,
call render and answer the encoded WebP with its headers. memoryCacheBackend keeps
ctx.cache in memory. A test can send requests to it as a display would:
describe("through the platform's protocol", () => {
const manifest = {
name: project.name,
description: project.description,
version: project.version,
network: project.network,
};
const serve = createAppServer({
apps: { [project.app_id]: hostApp(app, manifest) },
cacheBackend: memoryCacheBackend(),
assets,
});
// The protocol takes the render's time from the clock, so pin it.
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-12-31T20:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
/** The render a display in London gets for this query. */
function render(query: string) {
const url = `http://localhost/${project.app_id}/render?${query}`;
const headers = { "LD-Timezone": "Europe/London" };
return serve(new Request(url, { headers }));
}
it("answers a WebP with its refresh time", async () => {
const response = await render("show=Per%20cent");
expect(response.status).toBe(200);
const headers = response.headers;
expect(headers.get("Content-Type")).toBe("image/webp");
expect(headers.get("LD-Soft-TTL")).toBe(String(4 * HOUR_MS));
});
it("reads an option it does not offer as unset", async () => {
const offered = await render("show=Weeks");
const unset = await render("");
expect(await offered.bytes()).toEqual(await unset.bytes());
});
});Here the protocol sets ctx.now from the clock, so the test pins the clock itself with Vitest's
fake timers. The second test shows a rule from Settings:
an option the setting does not offer reads as unset.
Stub the network
Your code calls the global fetch, so a test replaces it:
import { afterEach, expect, it, vi } from "vitest";
import { quote } from "./quote";
afterEach(() => {
vi.unstubAllGlobals();
});
it("reads the quote the service sends", async () => {
const fetch = vi.fn(async () => Response.json({ text: "Hello" }));
vi.stubGlobal("fetch", fetch);
expect(await quote()).toBe("Hello");
expect(fetch).toHaveBeenCalledWith("https://api.example.com/quote");
});A test calls your code directly, so network.allowed_hosts does not apply in it; the
local preview and the platform hold requests to it.
Without a test runner
ledable developer render writes what the app draws for given settings and a given display, so a
script can compare the file with one you checked before:
ledable developer render --values away.json --out away.webp
cmp away.webp expected/away.webpIt renders at the current time on your machine's clock and cannot pin it, so this works for an
app whose image does not depend on the time, such as the
door sign. For anything that depends on ctx.now, use a test.