Component tests — compile, conform, snapshot, render
Tests are colocated as <file>.ink.test.ts (Vitest), next to the source they cover: the headless test in headless/, the styled test in styled/. They run the real compiler in-memory via @inkline/test-utils and assert across all 7 targets, plus real-DOM behavior on Angular SSR. Aim for ~100% line+branch on the component's own code.
Read first
- The actual
@inkline/test-utils exports — tooling/test-utils/src/index.ts (the prose runScenarioAcrossTargets is gone). Available: compileComponent, expectCompilationSuccess, expectNoDiagnostics, expectDiagnostics, expectCorrectFileExtensions, expectOutputContains, expectOutputNotContains, expectImports, assertConformance, snapshotOutput, resolveComponent, plus types ComponentTestResult, TargetName.
- The exemplar tests:
button/headless/IButtonBase.ink.test.ts (simple) and input/styled/IInput.ink.test.ts (comprehensive + Angular SSR).
ui/components/src/components/angular-ssr-helper.ts — mountStyledOnAngular(importMetaUrl, styledRel, headlessRels, props?).
.../reference/primitives.md — the per-target lowering table and expected notices.
Headless test — the baseline every component clears
import { describe, it, expect } from "vitest";
import {
compileComponent,
expectCompilationSuccess,
expectCorrectFileExtensions,
expectNoDiagnostics,
assertConformance,
snapshotOutput,
resolveComponent,
} from "@inkline/test-utils";
const BADGE = resolveComponent(import.meta.url, "./IBadgeBase.ink.tsx");
describe("Badge", () => {
it("compiles to all 7 targets without errors", async () => {
const result = await compileComponent(BADGE);
expectCompilationSuccess(result);
expect(Object.keys(result.files)).toHaveLength(7);
});
it("produces zero diagnostics", async () => {
expectNoDiagnostics(await compileComponent(BADGE));
});
it("produces correct file extensions per target", async () => {
expectCorrectFileExtensions(await compileComponent(BADGE));
});
it("passes conformance invariants for all targets", async () => {
assertConformance(await compileComponent(BADGE));
});
it("output matches snapshots", async () => {
expect(snapshotOutput(await compileComponent(BADGE))).toMatchSnapshot();
});
});
If the component uses two-way binding or hasSlot gating, replace expectNoDiagnostics with an explicit expected-notice assertion:
it("emits only the expected notices (Astro two-way INK0045, Qwik/Angular hasSlot INK0068)", async () => {
const result = await compileComponent(COMPONENT);
const unexpected = result.diagnostics.filter((d) => d.code !== "INK0045" && d.code !== "INK0068");
expect(unexpected.map((d) => `${d.code}: ${d.title}`)).toEqual([]);
expect(result.diagnostics.filter((d) => d.code === "INK0068")).toHaveLength(2); // Qwik + Angular
});
Styled test — add composition, imports, bindings, real DOM
On top of the baseline, assert what the styled layer is responsible for. Use a small helper const out = (r, t) => r.files[t] ?? [];.
- Composition — every headless part appears in the output:
expectOutputContains(out(result, target), "IInputControlBase") across targets.
- Recipe imports —
expectImports(out(result, "react"), "virtual:styleframe", ["inputRecipe", "inputPrefixRecipe", "inputSuffixRecipe"]).
- Slot gating per target —
props.prefix != null (React/Solid), !!$slots.prefix (Vue), {props.prefix} (Qwik), select="[slot=prefix]" (Angular).
- Two-way binding per target —
onUpdateValue (React), v-model:value="value" (Vue), bind:value={value} (Svelte), (valueChange)= (Angular).
- Snapshot —
expect(snapshotOutput(result)).toMatchSnapshot().
Real-DOM behavior — Angular SSR
The strongest assertion: render the composed component and check actual HTML. List the headless parts, then assert classes, native attribute reflection, ARIA, content projection (via __slots), and state. Remember Angular sorts recipe class keys alphabetically.
import { mountStyledOnAngular } from "../../angular-ssr-helper.ts";
describe("IInput (styled) on Angular SSR", () => {
const HEADLESS = [
"../headless/IInputBase.ink.tsx",
"../headless/IInputPrefixBase.ink.tsx",
"../headless/IInputSuffixBase.ink.tsx",
"../headless/IInputControlBase.ink.tsx",
];
const mount = (props?: Record<string, unknown>) =>
mountStyledOnAngular(import.meta.url, "./IInput.ink.tsx", HEADLESS, props);
it("renders the shell with recipe classes + the native control", async () => {
const { html } = await mount({
placeholder: "Amount",
name: "amount",
size: "md",
color: "light",
});
expect(html).toMatch(/<div[^>]*class="input input--color-light input--size-md"/);
expect(html).toMatch(/<input[^>]*class="input-field"/);
});
it("projects slot content", async () => {
const { html } = await mount({ __slots: { prefix: "$", suffix: "USD" } });
expect(html).toMatch(/<span[^>]*class="input-prefix[^"]*">\$<\/span>/);
});
it("reflects disabled onto the native control", async () => {
expect((await mount({ disabled: true })).html).toMatch(/<input[^>]*disabled/);
});
});
Coverage — exercise every branch
Drive ~100% line+branch on the component's executable code: every variant axis, every boolean state, every <Show>/<For>/slot branch, the ?? "" fallbacks, and the <textarea>-style alternate branches. Add cases until coverage is full.
Verify
cd ui/components && vp test green, then vp test --coverage and confirm ~100% line+branch on the new files. Report the coverage numbers. On the first run, review the new snapshot before committing it.
Exit criteria
Headless + styled tests pass; expected notices asserted explicitly; composition/imports/bindings/real-DOM covered; coverage ~100% line+branch with the report shown.
1---2name: test-component3description: Phase 4 of building an Inkline component — write colocated cross-target tests. Assert compilation to all 7 targets, expected diagnostics, conformance, output composition/imports/bindings, snapshots, and real-DOM behavior via Angular SSR. Targets ~100% line+branch coverage. Use when adding or strengthening a component's tests.4---56# Component tests — compile, conform, snapshot, render78Tests are colocated as `<file>.ink.test.ts` (Vitest), next to the source they cover: the headless test in `headless/`, the styled test in `styled/`. They run the real compiler in-memory via `@inkline/test-utils` and assert across all 7 targets, plus real-DOM behavior on Angular SSR. Aim for **~100% line+branch** on the component's own code.910## Read first11121. The **actual** `@inkline/test-utils` exports — `tooling/test-utils/src/index.ts` (the prose `runScenarioAcrossTargets` is gone). Available: `compileComponent`, `expectCompilationSuccess`, `expectNoDiagnostics`, `expectDiagnostics`, `expectCorrectFileExtensions`, `expectOutputContains`, `expectOutputNotContains`, `expectImports`, `assertConformance`, `snapshotOutput`, `resolveComponent`, plus types `ComponentTestResult`, `TargetName`.132. The exemplar tests: `button/headless/IButtonBase.ink.test.ts` (simple) and `input/styled/IInput.ink.test.ts` (comprehensive + Angular SSR).143. `ui/components/src/components/angular-ssr-helper.ts` — `mountStyledOnAngular(importMetaUrl, styledRel, headlessRels, props?)`.154. `.../reference/primitives.md` — the per-target lowering table and expected notices.1617## Headless test — the baseline every component clears1819```ts20import { describe, it, expect } from "vitest";21import {22 compileComponent,23 expectCompilationSuccess,24 expectCorrectFileExtensions,25 expectNoDiagnostics,26 assertConformance,27 snapshotOutput,28 resolveComponent,29} from "@inkline/test-utils";3031const BADGE = resolveComponent(import.meta.url, "./IBadgeBase.ink.tsx");3233describe("Badge", () => {34 it("compiles to all 7 targets without errors", async () => {35 const result = await compileComponent(BADGE);36 expectCompilationSuccess(result);37 expect(Object.keys(result.files)).toHaveLength(7);38 });39 it("produces zero diagnostics", async () => {40 expectNoDiagnostics(await compileComponent(BADGE));41 });42 it("produces correct file extensions per target", async () => {43 expectCorrectFileExtensions(await compileComponent(BADGE));44 });45 it("passes conformance invariants for all targets", async () => {46 assertConformance(await compileComponent(BADGE));47 });48 it("output matches snapshots", async () => {49 expect(snapshotOutput(await compileComponent(BADGE))).toMatchSnapshot();50 });51});52```5354If the component uses two-way binding or `hasSlot` gating, replace `expectNoDiagnostics` with an **explicit expected-notice** assertion:5556```ts57it("emits only the expected notices (Astro two-way INK0045, Qwik/Angular hasSlot INK0068)", async () => {58 const result = await compileComponent(COMPONENT);59 const unexpected = result.diagnostics.filter((d) => d.code !== "INK0045" && d.code !== "INK0068");60 expect(unexpected.map((d) => `${d.code}: ${d.title}`)).toEqual([]);61 expect(result.diagnostics.filter((d) => d.code === "INK0068")).toHaveLength(2); // Qwik + Angular62});63```6465## Styled test — add composition, imports, bindings, real DOM6667On top of the baseline, assert what the styled layer is responsible for. Use a small helper `const out = (r, t) => r.files[t] ?? [];`.6869- **Composition** — every headless part appears in the output: `expectOutputContains(out(result, target), "IInputControlBase")` across targets.70- **Recipe imports** — `expectImports(out(result, "react"), "virtual:styleframe", ["inputRecipe", "inputPrefixRecipe", "inputSuffixRecipe"])`.71- **Slot gating per target** — `props.prefix != null` (React/Solid), `!!$slots.prefix` (Vue), `{props.prefix}` (Qwik), `select="[slot=prefix]"` (Angular).72- **Two-way binding per target** — `onUpdateValue` (React), `v-model:value="value"` (Vue), `bind:value={value}` (Svelte), `(valueChange)=` (Angular).73- **Snapshot** — `expect(snapshotOutput(result)).toMatchSnapshot()`.7475### Real-DOM behavior — Angular SSR7677The strongest assertion: render the composed component and check actual HTML. List the headless parts, then assert classes, native attribute reflection, ARIA, content projection (via `__slots`), and state. Remember Angular **sorts recipe class keys alphabetically**.7879```ts80import { mountStyledOnAngular } from "../../angular-ssr-helper.ts";8182describe("IInput (styled) on Angular SSR", () => {83 const HEADLESS = [84 "../headless/IInputBase.ink.tsx",85 "../headless/IInputPrefixBase.ink.tsx",86 "../headless/IInputSuffixBase.ink.tsx",87 "../headless/IInputControlBase.ink.tsx",88 ];89 const mount = (props?: Record<string, unknown>) =>90 mountStyledOnAngular(import.meta.url, "./IInput.ink.tsx", HEADLESS, props);9192 it("renders the shell with recipe classes + the native control", async () => {93 const { html } = await mount({94 placeholder: "Amount",95 name: "amount",96 size: "md",97 color: "light",98 });99 expect(html).toMatch(/<div[^>]*class="input input--color-light input--size-md"/);100 expect(html).toMatch(/<input[^>]*class="input-field"/);101 });102103 it("projects slot content", async () => {104 const { html } = await mount({ __slots: { prefix: "$", suffix: "USD" } });105 expect(html).toMatch(/<span[^>]*class="input-prefix[^"]*">\$<\/span>/);106 });107108 it("reflects disabled onto the native control", async () => {109 expect((await mount({ disabled: true })).html).toMatch(/<input[^>]*disabled/);110 });111});112```113114## Coverage — exercise every branch115116Drive ~100% line+branch on the component's executable code: every variant axis, every boolean state, every `<Show>`/`<For>`/slot branch, the `?? ""` fallbacks, and the `<textarea>`-style alternate branches. Add cases until coverage is full.117118## Verify119120`cd ui/components && vp test` green, then `vp test --coverage` and confirm ~100% line+branch on the new files. Report the coverage numbers. On the first run, review the new snapshot before committing it.121122## Exit criteria123124Headless + styled tests pass; expected notices asserted explicitly; composition/imports/bindings/real-DOM covered; coverage ~100% line+branch with the report shown.