SmartTest

Plugins

The Plugin SDK is SmartTest's public extension API. Version 1 is frozen at SmartTest 1.0.0 and covers four contracts:

ContractInterfaceWhat it doesLoadable from a plugin in 1.0
Edge providerEdgeProviderAdds dependency edges that static analysis cannot seeyes
Runner adapterTestAdapterDiscovers and runs tests (Vitest, Jest, node --test…)not yet
Version-control adapterVersionControlAdapterReports what changed (Git, Subversion, Mercurial)not yet
Language analyzerLanguageAnalyzerTurns a source file into imports, symbols and referencesnot yet

SmartTest's built-in adapters implement the last three and pass the conformance kit. In 1.0 a plugin can contribute edge providers; registering runners, version control and analyzers from a plugin will come in a minor release, as new optional fields of SmartTestPlugin. The interfaces themselves will not change.

Everything is imported from the smarttest package:

import { definePlugin, type EdgeProvider, PLUGIN_API_VERSION } from "@tkmremi/smarttest/plugin-sdk";
import { assertConformance, checkEdgeProvider } from "@tkmremi/smarttest/plugin-sdk/conformance";

Every adapter, provider and plugin declares apiVersion: 1. SmartTest refuses a plugin built for another version (ST_PLUGIN_API_VERSION, exit 3).

Edge providers #

TypeScript and JavaScript imports, references and many conventions (environment keys, Prisma models, translation keys, routes) are understood out of the box. An edge provider covers what is left: a file read through a name built at run time, code generated from a schema, a service in another language. It returns edges between project files; from depends on to, so a change to to affects from and every test that reaches from.

// tools/sql-edges.mjs
// A call to the `query` helper: its first argument, as a string literal (group 2) or as code
// (group 3). Further arguments are ignored. Not matched: the helper's own definition
// (`function query(`) and other objects' methods (`pool.query(`).
const CALL = /(?<![\w.$]|function\s*)query\(\s*(?:(["'`])([\w-]+)\1|([^,)]*))/g;

/** @type {import("@tkmremi/smarttest/plugin-sdk").EdgeProvider} */
const sqlEdges = {
  apiVersion: 1,
  id: "sql-queries",
  getCapabilities: () => ({ fileEdges: true, unresolved: true, nonSourceFiles: true }),
  async provideEdges(ctx) {
    const all = new Set(await ctx.listFiles());
    const edges = [];
    const unresolved = [];
    for (const file of [...ctx.files].sort()) {
      if (ctx.isTest(file) || !file.startsWith("src/")) continue;
      for (const m of ((await ctx.readFile(file)) ?? "").matchAll(CALL)) {
        const name = m[2];
        if (!name) {
          unresolved.push({ file, reason: `query(${m[3]?.trim()}) is not a literal name` });
          continue;
        }
        const to = `sql/${name}.sql`;
        if (all.has(to)) edges.push({ from: file, to, kind: "REFERENCES", confidence: "high" });
        else unresolved.push({ file, reason: `query("${name}") has no ${to}` });
      }
    }
    return { edges, unresolved };
  },
};

/** @type {import("@tkmremi/smarttest/plugin-sdk").SmartTestPlugin} */
export default { apiVersion: 1, name: "sql-edges", edgeProviders: [sqlEdges] };

Register it in the configuration, as a path relative to the project root or as an installed package name:

import { defineConfig } from "@tkmremi/smarttest";

export default defineConfig({
  plugins: ["./tools/sql-edges.mjs"],
});

With this plugin, editing sql/find-user.sql selects only the tests that reach a file calling query("find-user"). Without it, SmartTest has no way to connect the SQL file to any test. The plugin-edges-vitest fixture is this example, run by the regression harness.

What SmartTest guarantees around a provider #

Contract #

MemberContract
apiVersion1
idStable, unique among providers; shown in warnings and uncertainty reasons
getCapabilities()fileEdges, unresolved, nonSourceFiles: each true, false or "partial". Anything not declared true is unsupported
provideEdges(ctx)Called once per analysis. Deterministic for the same files. Local only: no network (ADR-0004)
ctx.rootAbsolute project root
ctx.filesProject-relative POSIX paths of the analysed source files, tests included
ctx.listFiles()Every project file that is not excluded, non-code files included
ctx.readFile(path)File content, or undefined when it is missing or outside the project
ctx.isTest(path)Whether SmartTest treats the file as a test
result edges[]{ from, to, kind, confidence, reason? }; from/to project-relative POSIX paths; kind a graph edge kind such as REFERENCES, IMPORTS, DEPENDS_ON, CONFIGURES; confidence high, medium, low or unknown
result unresolved[]{ file, reason }

Capabilities, as SmartTest uses them (ADR-0005):

Edges are file to file in version 1: to changing affects all of from. A symbol-level edge (file#Symbol) can come later as an optional, declared capability.

Runner adapters #

interface TestAdapter {
  readonly apiVersion: 1;
  id: string;
  discover(): Promise<TestCase[]>;
  execute(selection: TestSelection, options: ExecutionOptions): Promise<TestRunResult>;
  collectCoverage?(): Promise<CoverageArtifact>;
  getCapabilities(): TestAdapterCapabilities;
}

Version-control adapters #

interface VersionControlAdapter {
  readonly apiVersion: 1;
  readonly id: string;
  detect(root: string): Promise<boolean>;
  getWorkingState(): Promise<WorkingState>;
  getChanges(base: RevisionRef, target?: RevisionRef): Promise<ChangeSet>;
  getFileContent(path: string, revision: RevisionRef): Promise<Uint8Array | null>;
  getCapabilities(): VersionControlCapabilities;
}

Language analyzers #

interface LanguageAnalyzer {
  readonly apiVersion: 1;
  readonly id: string;
  readonly version: string;
  supports(path: string): boolean;
  analyze(path: string, content: string): FileAnalysis;
}

FileAnalysis lists a file's imports, symbols (ids are <path>#<qualifiedName>), exports and references. Optional fields are enrichments: leaving one out makes selection coarser, never wrong. Report every import you cannot resolve statically in dynamicUnresolved; SmartTest turns it into uncertainty. Change version whenever the output can change, so cached analyses are recomputed. For a language SmartTest does not analyse, an edge provider is usually the simpler start.

Conformance kit #

@tkmremi/smarttest/plugin-sdk/conformance checks an implementation against the contract with a fixture you provide. It does not depend on a test framework: each function returns a report, and assertConformance throws with every failed check listed.

FunctionFixture
checkRunnerAdapter(adapter, fixture?)expectedFiles; run: { files, expect: { [file]: "passed" | "failed" } }
checkVcsAdapter(adapter, fixture)a working copy root with uncommitted changes, outside, base, expectedChanges, fileAtBase
checkLanguageAnalyzer(analyzer, fixture)files: { [path]: source }, expect: { imports, symbols, uncertain }
checkEdgeProvider(provider, fixture)root, files, allFiles, expectedEdges, expectedUnresolved
checkPlugin(plugin, fixtures?)the default export, and an edge-provider fixture per provider id

The provider reads files from disk, relative to root (through ctx.readFile), so the fixture needs those files under root. allFiles is only the listing ctx.listFiles() returns: files that are listed but never read, like the SQL file here, do not need to exist. Keep a small fixture project next to the test:

test/fixtures/sql-app/
  src/db.ts      export function query(name: string, ...args: unknown[]) { … }
  src/users.ts   export const findUser = (id: string) => query("find-user", id);
  src/report.ts  export const run = (name: string) => query(name);
// test/sql-edges.test.ts
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { assertConformance, checkPlugin } from "@tkmremi/smarttest/plugin-sdk/conformance";
import plugin from "../tools/sql-edges.mjs";

test("sql-edges conforms to the plugin API", async () => {
  const report = await checkPlugin(plugin, {
    edgeProviders: {
      "sql-queries": {
        root: fileURLToPath(new URL("./fixtures/sql-app/", import.meta.url)),
        files: ["src/db.ts", "src/users.ts", "src/report.ts"],
        allFiles: ["src/db.ts", "src/users.ts", "src/report.ts", "sql/find-user.sql"],
        expectedEdges: [{ from: "src/users.ts", to: "sql/find-user.sql" }],
        // The query name in report.ts is only known at run time. db.ts defines `query`
        // and is not reported.
        expectedUnresolved: ["src/report.ts"],
      },
    },
  });
  assertConformance(report);
});

With root: process.cwd() and no src/users.ts there, the provider reads nothing and the expected edge is missing.

The kit checks identity and apiVersion, that every capability is declared, the shape of every result (relative POSIX paths, known kinds and statuses), determinism, the expectations of your fixture, and declarations against behaviour: a runner that discovers tests without declaring discovery fails, so does a provider that points outside the analysed sources without declaring nonSourceFiles.

The semver promise #

Plugin API version 1 does not change within SmartTest 1.x. Minor releases may add optional members to the interfaces you implement (SmartTest handles their absence), members to the objects SmartTest passes you, capability keys (undeclared counts as unsupported), edge kinds, and conformance checks for behaviour the contract already required. Removing or renaming anything, or adding a required member, waits for a major version together with a new apiVersion. See Public contracts.

Edit this page on GitHub