Kits

Most pipelines start with the same four steps: run the Go tests, run pytest, lint with ESLint, run the Playwright suite. Each one needs the same care to be useful in senro: the right command line, a report format senro can read, and a way to rerun only the tests that failed. A kit is that care, packaged. It is a preset step for one tool, with per-test results already wired in, and it knows how to find the places in a repository where the tool applies.

import (
	"github.com/xavidop/senro/kit/gotest"
	"github.com/xavidop/senro/kit/playwright"
)

verify := p.Workflow("verify")
verify.Add("test", gotest.Step(gotest.Race()))
verify.Add("e2e", playwright.Step(playwright.Dir("e2e")))

A kit builds an ordinary *senro.StepBuilder. Everything on Steps still applies: chain Needs, Env, Timeout, Retry or When onto it as on any other step. If you would rather not write this file by hand, senro init detects which kits fit a repository and writes it.

Two forms: Step and Template

Every kit has the same surface. Below, <kit> stands for the kit’s package (gotest, pytest, eslint or playwright); the shared kit package itself has no Step or Template.

CallWhat it builds
<kit>.Step(opts...)One step, run from the directory given by the Dir option (the pipeline’s working directory by default)
<kit>.Template(opts...)A function for Expand(...).Template(...): one step per unit, run from the unit’s directory
<kit>.Detect(root)The directories under root where the kit applies, as []kit.Match

Step goes into a workflow with Add. (*senro.WorkflowBuilder).Add(id, sb) adopts a builder made elsewhere under id and returns it, so the rest of the chain works as it does after Step:

verify.Add("test", gotest.Step()).Needs("lint").Timeout(10 * time.Minute)

Add refuses, when the pipeline is built and naming the id: a builder that was already added (here or to another workflow), a handler builder, and a nil builder. The id rules are those of Step.

Template is for a repository with many modules or packages. It returns a fresh builder on every call, as Expand requires, and it ignores the Dir option because the unit decides where the step runs:

import (
	"github.com/xavidop/senro/kit/gotest"
	"github.com/xavidop/senro/unit/gowork"
)

verify.Expand("test", gowork.Modules()).
	Template(gotest.Template(gotest.Race()))

See Fan-out for what Expand does with the template.

Options every kit takes

OptionWhat it sets
Dir(d)The step’s working directory, relative to the repository root. Empty means the pipeline’s directory. Template ignores it
Args(a...)Extra arguments for the tool, placed as each section below says

Options apply in order, and the list options (Args, Packages, Tags, Paths) append.

The report is written for you

The three kits whose tools write a JUnit file put it under .senro/ in the step’s working directory (.senro/pytest.xml, .senro/eslint.xml, .senro/playwright.xml). Every kit skips .senro when it walks a repository, so a report is never mistaken for source. Add .senro/ to your .gitignore.

Kits are not cached

Neither form calls Pure(). Whether a test run may be skipped when nothing changed is your call, because only you know whether the tests touch a network, a clock or a database. To opt in, add it yourself and declare the inputs:

verify.Expand("test", gowork.Modules()).
	Template(func(u senro.Unit) *senro.StepBuilder {
		return gotest.Template()(u).Pure().Inputs(u.Sources()...)
	})

See Caching a step for what a Pure() step promises.

gotest

Runs go test -json, then -race if asked, then -tags a,b if asked, then your Args, then the packages (./... unless you set some):

go test -json [-race] [-tags a,b] [Args...] <Packages... | ./...>

The JSON stream is read with testreport.GoTestJSON(), so a failing run names the tests that failed and senro rerun --failed-tests reruns only those.

OptionWhat it does
Packages(p...)The packages to test. Default ./...
Race()Adds -race
Tags(t...)Adds -tags with the tags comma-joined
Args(a...)Extra go test arguments, after -race and -tags and before the packages

Where it applies: every directory holding a go.mod. For a repository with several, use Template over gowork.Modules().

pytest

<runner...> pytest --junitxml=.senro/pytest.xml [Args...] [Paths...]

The report is read with testreport.JUnitXML(".senro/pytest.xml") and a rerun selects tests with -k "a or b": Selecting("-k", "{tests}").JoinedBy(" or ").

<runner> is the prefix that puts pytest inside the project’s environment, found from the nearest lockfile at or above the step’s directory:

LockfileRunner
uv.lockuv run
poetry.lockpoetry run
nonepython -m

The runner is resolved when the pipeline is built, relative to the directory the pipeline runs in (where senro run was started). Runner("pipenv", "run") overrides the detection.

OptionWhat it does
Paths(p...)Files or directories pytest collects. Default none, so pytest uses its own configuration
Runner(argv...)The command prefix, instead of the detected one
Args(a...)Extra pytest arguments, after --junitxml and before the paths

Where it applies: a directory matches on the first of these it finds: a [tool.pytest.ini_options] table in pyproject.toml; pytest among [project] dependencies, among any [project.optional-dependencies] group, or among the [tool.poetry] dependencies or dependency groups; a pytest.ini; a [pytest] section in tox.ini; a [tool:pytest] section in setup.cfg. A pyproject.toml that does not parse is an error naming the file. For several projects, use Template over pyproject.Packages().

eslint

<runner...> eslint <Paths... | .> --format junit --output-file .senro/eslint.xml [Args...]

The report is read with testreport.JUnitXML(".senro/eslint.xml"). ESLint’s JUnit cases are rule hits per file, not test names a rerun could select, so this kit declares no selection and a rerun of the step runs it whole.

<runner> comes from the nearest JavaScript lockfile at or above the step’s directory:

LockfileRunner
pnpm-lock.yamlpnpm exec
yarn.lockyarn
bun.lock or bun.lockbbunx
package-lock.json, or nonenpx
OptionWhat it does
Paths(p...)Files or directories to lint. Default .
Args(a...)Extra ESLint arguments, after --output-file

Where it applies: a directory with a package.json and either an ESLint config file (eslint.config.js, .mjs, .cjs, .ts, .mts or .cts; .eslintrc; .eslintrc.js, .cjs, .json, .yaml or .yml) or eslint in dependencies or devDependencies. A package.json that does not parse is an error naming the file. There is no graph to fan out over here: one step per match, each with its Dir.

playwright

<runner...> playwright test --reporter=list,junit [Args...]

with PLAYWRIGHT_JUNIT_OUTPUT_NAME=.senro/playwright.xml set in the step’s environment. The report is read with testreport.JUnitXML(".senro/playwright.xml"), so a failing run names the tests that failed. Rerunning only those tests is not available for Playwright: its JUnit reporter joins a test’s title path with ›, while --grep matches the title path joined with spaces, so a name from the report cannot be handed back to -g. This kit declares no selection, and a rerun of the step runs it whole. The runner is found the same way as for eslint.

OptionWhat it does
Args(a...)Extra playwright test arguments, after --reporter

Where it applies: a directory holding a playwright.config.ts, .js, .mjs, .cjs, .mts or .cts. One step per match, each with its Dir.

Finding where a kit applies

Detect(root) returns a []kit.Match for one kit; kit/all.Detect(ctx, root) runs all four and returns the union sorted by kit and then directory. A kit.Match has the kit’s name, the directory relative to root with forward slashes (. for the root), and a Reason: the file that decided it, such as go.mod or pyproject.toml [tool.pytest.ini_options].

Detection walks the tree in sorted order and skips every directory whose name starts with . or _ (.git included), plus node_modules, vendor, testdata, venv, env, build, dist, target and site-packages. A directory that cannot be read is skipped rather than stopping the walk. kit/all is the one package that imports all four kits, so a pipeline that only wants gotest does not link the others.

Writing a kit

A kit is an ordinary package, and nothing stops you writing one for another tool. The kit package holds the helpers the four built-in kits share, and they are public for that reason:

  • kit.Walk(root, fn) calls fn for root and every directory below it that detection does not skip, in lexical order, with the path relative to root in forward slashes.
  • kit.FirstFile(dir, names...) returns the first of names that is a regular file in dir, bare, ready for a Match’s Reason.
  • kit.UnitDir(dir) turns a unit’s directory into a step’s working directory: "" for the root, dir otherwise.
  • kit.SortMatches(ms) sorts matches by kit and then directory, so the same repository always gives the same list.
  • kit.ReportPath(name) is where a kit called name writes its report: .senro/<name>.xml.

What kits do not do

  • They do not install anything. The tool must already be on the PATH of wherever the step runs.
  • They do not call Pure(); see above.
  • They do not choose a step id or an order. You pick the id in Add, and nothing here adds Needs: unit tests and a linter are independent checks.