Commands, settings, SDK functions, anything in the docs.

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.

year-progress/index.tsx
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")] };
  },
});
The year-progress example on a LEDABLE panel

Set up Vitest

Install it in the project:

Terminal
npm install --save-dev vitest

Then 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.

year-progress/vitest.config.ts
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:

year-progress/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:

package.json
{
  "scripts": {
    "test": "ledable developer check && vitest run"
  }
}
Terminal
npm test

Test 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.

year-progress/index.test.ts
/** 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:

year-progress/index.test.ts
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:

year-progress/index.test.ts
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:

TypeScript
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:

Terminal
ledable developer render --values away.json --out away.webp
cmp away.webp expected/away.webp

It 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.