Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,14 @@ sources are. For a mount buck keeps it too, so a dependency bump downloads only
module versions accumulate but are inert, since go takes only what `go.sum` names out of the proxy view.
Otherwise buck clears it before the fetch, and the cache content depends only on the fetch's inputs.

A `go.package()` that sets `test_packages` also compiles the tests of those packages in `go_build`, with
`go test -c`, one test binary per package. The test binaries use go's build cache of the same action, so the
project's packages compile once for the binaries and the tests. A test that does not compile fails the
build. The test compile runs without vet, because a vet finding would fail the build too. `go.test()` runs
each test binary under `buck test` in the package's box, in the package's directory, with the sources
read-only. A pattern in `test_packages` that matches a package outside `src` fails the build, because that
package's directory does not exist when the tests run.

### Filesystem layer representation

Buck directory artifacts cannot faithfully store overlay whiteout devices, opaque-directory xattrs, or a
Expand Down
35 changes: 35 additions & 0 deletions docs/user/go.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,41 @@ go.package(
)
```

## Running a project's tests

Set `test_packages` on the `go.package()` to compile the project's tests in its build, and declare a
`go.test()` that runs them:

```Starlark
go.package(
name = "hello",
box = ":go.box",
packages = {"hello-cli": "./cmd/hello-cli"},
test_packages = ["./..."],
)

go.test(
name = "hello-test",
package = ":hello",
)
```

`buck test :hello-test` runs the tests in the package's box, without network.

- `test_packages` lists Go package patterns relative to the module root, such as `["./..."]` or
`["./pkg/...", "./internal/store"]`. The build compiles one test binary for each matched package that has
`_test.go` files, with the same tags, cgo setting and `linker_flags` as the binaries. Building the
binaries also compiles the tests.
- A test that does not compile fails the build of the `go.package()`, and with it the binaries. go's vet
checks do not run, so a vet finding fails neither the build nor the tests.
- A pattern that matches a package outside `src`, such as a dependency's package, fails the build.
- Each test binary runs in its package's directory, as with `go test`, so a test finds its `testdata/`.
The sources are read-only while the tests run, so a test that writes into its package directory fails.
- `args` passes arguments to every test binary. A test binary takes the `-test.` form of the `go test`
flags, such as `["-test.short", "-test.skip=TestNetwork"]`. Each binary has a timeout of 10 minutes,
which `-test.timeout` overrides.
- `labels` are passed to the test runner, so `buck test --exclude <label>` can leave the test out.

## What the builder box needs

```Starlark
Expand Down
14 changes: 14 additions & 0 deletions examples/image-go-project/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,27 @@ go.package(
name = "hello",
box = ":go.box",
packages = {"hello-cli": "./cmd/hello-cli"},
test_packages = ["./..."],
)

go.test(
name = "hello-test",
labels = ["image"],
package = ":hello",
)

# A project with no dependencies at all, which therefore commits no go.sum and fetches nothing.
# Without `packages`, the module's only main package is built under the target's name.
go.package(
name = "nodeps",
box = ":go.box",
test_packages = ["./..."],
)

go.test(
name = "nodeps-test",
labels = ["image"],
package = ":nodeps",
)

# The local checkout must override this fetch, so its revision is deliberately a placeholder.
Expand Down
17 changes: 17 additions & 0 deletions examples/image-go-project/hello/cmd/hello-cli/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: Amutable GmbH <https://amutable.com/>
// SPDX-License-Identifier: MPL-2.0

package main

import (
"strings"
"testing"

"rsc.io/quote"
)

func TestQuote(t *testing.T) {
if got := quote.Go(); !strings.Contains(got, "communicating") {
t.Fatalf("unexpected quote: %q", got)
}
}
12 changes: 12 additions & 0 deletions examples/image-go-project/nodeps/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Amutable GmbH <https://amutable.com/>
// SPDX-License-Identifier: MPL-2.0

package main

import "testing"

func TestMessage(t *testing.T) {
if message != "no dependencies here\n" {
t.Fatalf("unexpected message: %q", message)
}
}
13 changes: 13 additions & 0 deletions go/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ tine_python_binary(
],
)

# Runs inside the box offline. It runs each test binary that the build compiled in the binary's package directory.
tine_python_binary(
name = "runner",
main = "runner.py",
visibility = ["PUBLIC"],
deps = [
"//:specs",
"//rootfs:rootfs",
],
)

box_python_test(
name = "test",
box = "//tools:dev.box",
Expand All @@ -42,6 +53,8 @@ box_python_test(
"build_test.py",
"fetch.py",
"fetch_test.py",
"runner.py",
"runner_test.py",
"workspace.py",
"workspace_test.py",
],
Expand Down
107 changes: 92 additions & 15 deletions go/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import tempfile
from contextlib import ExitStack
from pathlib import Path
from typing import TypedDict, cast
from typing import Any, TypedDict, cast

import specs
import util
Expand Down Expand Up @@ -45,36 +45,89 @@ class Spec(TypedDict):
src: str
# The build tags gating which of the project's files compile.
tags: list[str]


def _package(selector: str, workspace: Path, env: dict[str, str]) -> str:
"""Resolve a selector to exactly one main package before building an executable."""
if not selector or selector.startswith("-") or selector.endswith(".go"):
util.fail(f"go-build: invalid package selection {selector!r}")
# The Go package patterns whose tests to compile, or None to compile no tests.
test_packages: list[str] | None
# The output directory for the test binaries and their manifest, or None to compile no tests.
tests: str | None


class Test(TypedDict):
# The test binary's file name in the tests directory.
binary: str
# The package's import path.
package: str
# The package's directory relative to src. go test runs a test binary in this directory.
dir: str


def _list(patterns: list[str], fields: str, workspace: Path, env: dict[str, str]) -> list[dict[str, Any]]:
"""Return the packages that `patterns` match, as go list reports them."""
for pattern in patterns:
if not pattern or pattern.startswith("-") or pattern.endswith(".go"):
util.fail(f"go-build: invalid package selection {pattern!r}")
listed = subprocess.run(
["go", "list", "-json=ImportPath,Name", selector],
["go", "list", f"-json={fields}", *patterns],
check=True,
cwd=workspace,
env=env,
stdout=subprocess.PIPE,
text=True,
)
# go prints one object per package, back to back. A pattern such as the `./...` an undeclared
# `packages` stands for also matches the libraries beside the program, which are not candidates.
# go prints one object per package, back to back.
decoder = json.JSONDecoder()
mains: list[str] = []
packages: list[dict[str, Any]] = []
rest = listed.stdout.lstrip()
while rest:
package, end = cast(tuple[dict[str, str], int], decoder.raw_decode(rest))
if package["Name"] == "main":
mains.append(package["ImportPath"])
package, end = cast(tuple[dict[str, Any], int], decoder.raw_decode(rest))
packages.append(package)
rest = rest[end:].lstrip()
return packages


def _package(selector: str, workspace: Path, env: dict[str, str]) -> str:
"""Resolve a selector to exactly one main package before building an executable."""
# A pattern such as the `./...` an undeclared `packages` stands for also matches the libraries
# beside the program, which are not candidates.
mains = [
package["ImportPath"]
for package in _list([selector], "ImportPath,Name", workspace, env)
if package["Name"] == "main"
]
if len(mains) != 1:
util.fail(
f"go-build: package selection {selector!r} must resolve to exactly one main package, "
f"found {mains}"
)
return mains[0]
return cast(str, mains[0])


def _test_packages(
patterns: list[str], workspace: Path, src: Path, env: dict[str, str]
) -> list[tuple[str, str]]:
"""Return the import path and the directory relative to `src` of each matched package that has tests."""
found: list[tuple[str, str]] = []
fields = "ImportPath,Dir,TestGoFiles,XTestGoFiles"
for package in _list(patterns, fields, workspace, env):
if not package.get("TestGoFiles") and not package.get("XTestGoFiles"):
continue
# A pattern can name a package of a dependency, which go extracts into the module cache in
# the scratch space. go.test runs each test binary in its package directory, and that
# directory does not exist when the tests run.
directory = Path(package["Dir"]).resolve()
if not directory.is_relative_to(src.resolve()):
util.fail(f"go-build: test package {package['ImportPath']} is not in src")
found.append((package["ImportPath"], str(directory.relative_to(src.resolve()))))
return found


def _test_command(binary: Path, package: str, linker_flags: list[str]) -> list[str]:
"""Compile the tests of one package into a test binary."""
# go test runs a subset of go vet before it compiles the tests, also with -c. A vet finding would
# fail the build of the project's binaries along with the tests.
cmd = ["go", "test", "-c", "-vet=off", "-o", str(binary)]
if linker_flags:
cmd.append("-ldflags=" + " ".join(linker_flags))
return cmd + [package]


def _build_command(binary: Path, package: str, linker_flags: list[str]) -> list[str]:
Expand Down Expand Up @@ -140,10 +193,14 @@ def build_go(spec: Spec) -> None:
# enough to need a C compiler in the box and to link the binary dynamically.
env["CGO_ENABLED"] = "1" if spec["cgo"] else "0"

tests = scratch / "tests"

with ExitStack() as stack:
outputs = {Path(spec["bin"]): out}
if spec["gocache"] is not None:
outputs[Path(spec["gocache"])] = gocache
if spec["tests"] is not None:
outputs[Path(spec["tests"])] = tests
stack.enter_context(rootfs.readonly_project(Path.cwd(), outputs))
util.remove_previous_binaries(out)

Expand All @@ -157,6 +214,26 @@ def build_go(spec: Spec) -> None:
env=env,
)

if spec["tests"] is None or spec["test_packages"] is None:
return
util.remove_previous_binaries(tests)
# The tests compile in this action because it already has go's build cache filled with the
# project's packages. A separate action would compile the packages again.
manifest: list[Test] = []
for index, (package, directory) in enumerate(
_test_packages(spec["test_packages"], workspace, Path(spec["src"]), env)
):
# Two packages can have the same name, so the binaries are numbered.
binary = f"{index}.test"
subprocess.run(
_test_command(tests / binary, package, spec["linker_flags"]),
check=True,
cwd=workspace,
env=env,
)
manifest.append(Test(binary=binary, package=package, dir=directory))
(tests / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")


def main(argv: list[str] | None = None) -> None:
build_go(specs.parse(Spec, "go-build", argv))
Expand Down
Loading
Loading