The generator wrote `accept.<stream>.<kind>` for a kind that rides a stream (step on check and delivery, run on check), so the runner's step landed under the named worker `accept.check.*` with `step` read as its asker. A-170 has the kind in the body only: the generated accept subject of a streamed kind is the queue's, `accept.<stream>`; its generated names stay `<stream>.<kind>`. The three build seats' contracts and fixtures are regenerated. Builds: design 47 G §600, A-170, A-184 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
380 lines
17 KiB
Go
380 lines
17 KiB
Go
package seatgen
|
|
|
|
import (
|
|
"encoding/json"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
re "regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
|
)
|
|
|
|
// TestEachSeatDefinedWholeIsAGoModuleOfItsOwn is ADR 0625's generator check (§1, §2, §3): there is no seat set and no
|
|
// root Go module (ADR 0635 §1), each seat defined whole is a Go module of its own holding its package alone, the
|
|
// parts it includes are written into it, a definition at the seat-list grain generates nothing, and no generated Go file
|
|
// imports anything outside the standard library.
|
|
func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
|
|
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files, err := Generate(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mods := map[string]string{}
|
|
for p, b := range files {
|
|
if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, "go.mod") {
|
|
mods[strings.TrimSuffix(p, "go.mod")] = strings.SplitN(string(b), "\n", 2)[0]
|
|
}
|
|
}
|
|
want := map[string]string{
|
|
"go/seats/aregistry/": "module example.org/contracts/go/seats/aregistry",
|
|
"go/seats/bregistry/": "module example.org/contracts/go/seats/bregistry",
|
|
}
|
|
for dir, line := range want {
|
|
if mods[dir] != line {
|
|
t.Errorf("%sgo.mod says %q, want %q", dir, mods[dir], line)
|
|
}
|
|
}
|
|
// Every seat the definitions mark whole has its own module, whatever the seat set says of it (ADR 0627: the seat
|
|
// set marks ten whole, and none is without its package).
|
|
for name, d := range m.Seats {
|
|
if d.Grain == seatdef.Whole && mods["go/seats/"+pkgName(name)+"/"] == "" {
|
|
t.Errorf("the seat %s is defined whole and has no Go module of its own", name)
|
|
}
|
|
}
|
|
if len(mods) != len(want) {
|
|
t.Errorf("Go modules %v, want %v", mods, want)
|
|
}
|
|
for p, b := range files {
|
|
if !strings.HasPrefix(p, "go/") || !strings.HasSuffix(p, ".go") {
|
|
continue
|
|
}
|
|
dir := p[:strings.LastIndex(p, "/")+1]
|
|
if mods[dir] == "" {
|
|
t.Errorf("%s is in no seat's own Go module", p)
|
|
}
|
|
f, err := parser.ParseFile(token.NewFileSet(), p, b, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, im := range f.Imports {
|
|
if path := strings.Trim(im.Path.Value, `"`); strings.Contains(strings.SplitN(path, "/", 2)[0], ".") {
|
|
t.Errorf("%s imports %s: a generated package imports nothing outside the standard library", p, path)
|
|
}
|
|
}
|
|
}
|
|
for _, seat := range []string{"aregistry", "bregistry"} {
|
|
src := string(files["go/seats/"+seat+"/contract.go"])
|
|
if !strings.Contains(src, "type Ref struct") || !strings.Contains(src, "type PublishedEvent") {
|
|
t.Errorf("%s does not hold the part's types whole", seat)
|
|
}
|
|
// A moment (format date-time) is the standard library's time in Go, a required one by value and an optional one
|
|
// by pointer (a zero time is not an absent one), and a string in TypeScript.
|
|
if !re.MustCompile(`Pushed\s+time\.Time\s+`+"`json:\"pushed\"`").MatchString(src) || !re.MustCompile(`At\s+\*time\.Time\s+`+"`json:\"at,omitempty\"`").MatchString(src) ||
|
|
!(strings.Contains(src, "\t\"time\"\n") || strings.Contains(src, "import \"time\"")) {
|
|
t.Errorf("%s does not write a date-time as time.Time:\n%s", seat, src)
|
|
}
|
|
if ts := string(files["ts/seats/"+seat+"/parts/registry.d.ts"]); !strings.Contains(ts, `"pushed": string;`) || !strings.Contains(ts, `"at"?: string;`) {
|
|
t.Errorf("%s's TypeScript does not write a date-time as a string:\n%s", seat, ts)
|
|
}
|
|
// A scalar typed ["T","null"] is a pointer in Go and `T | null` in TypeScript, so a declared zero survives with its type.
|
|
for _, w := range []string{`Floor\s+\*float64\s+` + "`json:\"floor,omitempty\"`", `Limit\s+\*int64\s+` + "`json:\"limit,omitempty\"`", `Label\s+\*string\s+` + "`json:\"label,omitempty\"`", `On\s+\*bool\s+` + "`json:\"on,omitempty\"`"} {
|
|
if !re.MustCompile(w).MatchString(src) {
|
|
t.Errorf("%s does not write a nullable scalar as a pointer (%s):\n%s", seat, w, src)
|
|
}
|
|
}
|
|
if ts := string(files["ts/seats/"+seat+"/parts/registry.d.ts"]); !strings.Contains(ts, `"floor"?: number | null;`) || !strings.Contains(ts, `"on"?: boolean | null;`) || !strings.Contains(ts, `"label"?: string | null;`) {
|
|
t.Errorf("%s's TypeScript does not write a nullable scalar as T | null:\n%s", seat, ts)
|
|
}
|
|
// A string's enum is a constant per value, so a caller names the definition's value (A3t: the removal reason).
|
|
if !re.MustCompile(`RefReasonRetention\s+= "retention"`).MatchString(src) || !re.MustCompile(`RefReasonTagGone\s+= "tag-gone"`).MatchString(src) {
|
|
t.Errorf("%s does not name the part's enum values:\n%s", seat, src)
|
|
}
|
|
}
|
|
for p := range files {
|
|
if strings.Contains(p, "onestore") || p == "go/seats/seats.go" || p == "go/go.mod" || p == "ts/package.json" || p == "ts/src/seats.ts" {
|
|
t.Errorf("%s is written: a seat list is not generated, and a definition at its grain generates nothing", p)
|
|
}
|
|
}
|
|
|
|
// The npm side (ADR 0625 §1): the indexer counts an npm package a lock resolves, so each seat defined whole is an
|
|
// npm package of its own (`<package>-<seat>` at ts/seats/<seat without hyphens>), holding its parts' types whole and
|
|
// importing nothing outside itself; there is no package above them.
|
|
pkgs := map[string]string{}
|
|
for p, b := range files {
|
|
if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, "package.json") {
|
|
var pj struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(b, &pj); err != nil {
|
|
t.Fatalf("%s: %v", p, err)
|
|
}
|
|
pkgs[strings.TrimSuffix(p, "package.json")] = pj.Name
|
|
}
|
|
}
|
|
wantPkgs := map[string]string{
|
|
"ts/seats/aregistry/": "@example/contracts-a-registry",
|
|
"ts/seats/bregistry/": "@example/contracts-b-registry",
|
|
}
|
|
if len(pkgs) != len(wantPkgs) {
|
|
t.Errorf("npm packages %v, want %v", pkgs, wantPkgs)
|
|
}
|
|
for dir, name := range wantPkgs {
|
|
if pkgs[dir] != name {
|
|
t.Errorf("%spackage.json names %q, want %q", dir, pkgs[dir], name)
|
|
}
|
|
}
|
|
for p, b := range files {
|
|
if !strings.HasPrefix(p, "ts/") || !strings.HasSuffix(p, ".ts") {
|
|
continue
|
|
}
|
|
dir := ""
|
|
for d := range pkgs {
|
|
if strings.HasPrefix(p, d) && len(d) > len(dir) {
|
|
dir = d
|
|
}
|
|
}
|
|
if dir == "" || (p != dir+"index.d.ts" && !strings.HasPrefix(p, dir+"parts/")) {
|
|
t.Errorf("%s is in no npm package's files", p)
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
i := strings.Index(line, ` from "`)
|
|
if l := strings.TrimSpace(line); i >= 0 && (strings.HasPrefix(l, "import") || strings.HasPrefix(l, "export")) {
|
|
from := strings.Trim(line[i+len(` from `):], `";`)
|
|
if !strings.HasPrefix(from, "./") {
|
|
t.Errorf("%s imports %s: a generated npm package imports only its own files", p, from)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// F7: a published package is built from its own lock (mesh-build-node refuses one with none), and a generated one has
|
|
// no dependency to lock: its lock names the package alone and its package.json has no script and no dependency.
|
|
for _, seat := range []string{"aregistry", "bregistry"} {
|
|
var pj, lock map[string]any
|
|
if err := json.Unmarshal(files["ts/seats/"+seat+"/package.json"], &pj); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(files["ts/seats/"+seat+"/package-lock.json"], &lock); err != nil {
|
|
t.Fatalf("%s has no readable package-lock.json: %v", seat, err)
|
|
}
|
|
for _, k := range []string{"scripts", "dependencies", "devDependencies"} {
|
|
if _, ok := pj[k]; ok {
|
|
t.Errorf("%s's package.json has %s: a generated package needs no compiler or dependency", seat, k)
|
|
}
|
|
}
|
|
if pk, _ := lock["packages"].(map[string]any); len(pk) != 1 || lock["name"] != pj["name"] {
|
|
t.Errorf("%s's lock %v does not name its package alone", seat, lock)
|
|
}
|
|
}
|
|
for _, seat := range []string{"aregistry", "bregistry"} {
|
|
if !strings.Contains(string(files["ts/seats/"+seat+"/index.d.ts"]), "export type PublishedEvent = registry.PublishedEvent") ||
|
|
!strings.Contains(string(files["ts/seats/"+seat+"/parts/registry.d.ts"]), "export type PublishedEvent") {
|
|
t.Errorf("the npm package of %s does not hold the part's types whole", seat)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCallersAreTheFormsOfTheFormat is design 47 G §339's check: the generator refuses a definition (or a part)
|
|
// whose verb or accepted kind names a caller that is no form of §336, naming the entry, and the contracts it writes
|
|
// carry no caller check.
|
|
func TestCallersAreTheFormsOfTheFormat(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, callers, refused string
|
|
}{
|
|
{"every form", `["operator","healers","persons","sessions","invokers","seat:mesh-wants","module:gitea","holder:node-backup@same-node"]`, ""},
|
|
{"text", `"any caller whose role grants it"`, "any caller whose role grants it"},
|
|
{"a bare seat name", `["mesh-wants"]`, "mesh-wants"},
|
|
{"holder without the node", `["holder:node-backup"]`, "holder:node-backup"},
|
|
{"seat with no name", `["seat:"]`, "seat:"},
|
|
} {
|
|
for _, place := range []string{"verbs", "accepts"} {
|
|
t.Run(tc.name+"/"+place, func(t *testing.T) {
|
|
raw := []byte(`{"format":1,"seat":"x","` + place + `":[{"name":"v","callers":` + tc.callers + `}]}`)
|
|
err := checkCallers("seats/x/seat.json", raw)
|
|
switch {
|
|
case tc.refused == "" && err != nil:
|
|
t.Fatalf("refused: %v", err)
|
|
case tc.refused != "" && (err == nil || !strings.Contains(err.Error(), tc.refused) || !strings.Contains(err.Error(), "seats/x/seat.json")):
|
|
t.Fatalf("error %v, want one naming %q", err, tc.refused)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
// Load refuses an unknown entry in a definition before it reads it.
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(dir+"/seats/x", 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
def := `{"format":1,"seat":"x","scope":"mesh","grain":"seat-list","verbs":[{"name":"v","callers":["somebody"]}]}`
|
|
if err := os.WriteFile(dir+"/seats/x/seat.json", []byte(def), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(dir, "", ""); err == nil || !strings.Contains(err.Error(), `"somebody"`) {
|
|
t.Fatalf("Load error %v, want one naming the entry", err)
|
|
}
|
|
// The generated contracts of the testdata hold no caller check.
|
|
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files, err := Generate(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for p, b := range files {
|
|
if strings.Contains(strings.ToLower(string(b)), "callers") && strings.HasSuffix(p, ".go") {
|
|
t.Errorf("%s carries a caller check", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestATypeScriptContractIsJavaScriptWithItsDeclarations is design 47 G §341's check: each seat's npm package holds the
|
|
// contract as index.js with its index.d.ts (and each part the same), no TypeScript source and no tsconfig, so a
|
|
// TypeScript module imports it with no build step and no tsc of the contract.
|
|
func TestATypeScriptContractIsJavaScriptWithItsDeclarations(t *testing.T) {
|
|
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files, err := Generate(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"index.js", "index.d.ts", "parts/registry.js", "parts/registry.d.ts", "package.json", "package-lock.json"} {
|
|
if _, ok := files["ts/seats/aregistry/"+want]; !ok {
|
|
t.Errorf("the seat's npm package lacks %s", want)
|
|
}
|
|
}
|
|
for p, b := range files {
|
|
if !strings.HasPrefix(p, "ts/") {
|
|
continue
|
|
}
|
|
if (strings.HasSuffix(p, ".ts") && !strings.HasSuffix(p, ".d.ts")) || strings.HasSuffix(p, "tsconfig.json") {
|
|
t.Errorf("%s is TypeScript source or a compiler's setting: the contract needs no tsc", p)
|
|
}
|
|
if strings.HasSuffix(p, ".js") {
|
|
for _, typed := range []string{": string", "interface ", " as const", "export type ", "import type", "declare "} {
|
|
if strings.Contains(string(b), typed) {
|
|
t.Errorf("%s holds TypeScript syntax %q: it is JavaScript", p, typed)
|
|
}
|
|
}
|
|
if node, err := exec.LookPath("node"); err == nil {
|
|
path := filepath.Join(t.TempDir(), "check.mjs")
|
|
if err := os.WriteFile(path, b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.Command(node, "--check", path).CombinedOutput(); err != nil {
|
|
t.Errorf("%s is no JavaScript module: %v\n%s", p, err, out)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var pkg struct {
|
|
Types string `json:"types"`
|
|
Exports map[string]map[string]string `json:"exports"`
|
|
Scripts map[string]string `json:"scripts"`
|
|
}
|
|
if err := json.Unmarshal(files["ts/seats/aregistry/package.json"], &pkg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkg.Types != "./index.d.ts" || pkg.Exports["."]["default"] != "./index.js" || pkg.Exports["."]["types"] != "./index.d.ts" || len(pkg.Scripts) != 0 {
|
|
t.Errorf("package.json says %+v", pkg)
|
|
}
|
|
if !strings.Contains(string(files["ts/seats/aregistry/index.d.ts"]), `import type * as registry from "./parts/registry.js";`) {
|
|
t.Error("the declarations do not import the part")
|
|
}
|
|
}
|
|
|
|
// TestADefinitionsActsSubjectsKeepAndCallersAreRead is design 47 G §89, §203, §260, §324, §331's check for the
|
|
// generator: it reads `acts` (a step on every, a language and `kind: image`), `subject`, `keep` and `callers`, and
|
|
// writes each event's subject and the stream `SEAT_<seat>_<event>` of one that names `keep`.
|
|
func TestADefinitionsActsSubjectsKeepAndCallersAreRead(t *testing.T) {
|
|
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if acts := m.Seats["a-registry"].Acts; len(acts) != 3 || acts[0].On != "kind:image" || acts[1].On != "every" || acts[2].On != "go" {
|
|
t.Errorf("acts read as %+v", acts)
|
|
}
|
|
files, err := Generate(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := string(files["go/seats/aregistry/contract.go"])
|
|
for _, want := range []string{
|
|
`const StreamMilestoneAsked = "SEAT_a-registry_milestone-asked"`,
|
|
`const StreamOwed = "SEAT_a-registry_owed"`,
|
|
"func SubjectMilestoneAsked(milestone string) string {\n\treturn \"mesh.seat.a-registry.event.milestone-asked.\" + milestone\n}",
|
|
`SubjectMilestoneAskedAll = "mesh.seat.a-registry.event.milestone-asked.*"`,
|
|
`func SubjectOwed(repository, number string) string {`,
|
|
"func SubjectStepEnded(node string) string { return \"mesh.seat.a-registry.event.step-ended.\" + node }",
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("the Go contract lacks %q:\n%s", want, src)
|
|
}
|
|
}
|
|
if strings.Contains(src, "StreamStepEnded") {
|
|
t.Error("an event naming no keep has a stream of its own")
|
|
}
|
|
js := string(files["ts/seats/aregistry/index.js"])
|
|
dts := string(files["ts/seats/aregistry/index.d.ts"])
|
|
for _, want := range []string{`export const milestoneAskedStream = "SEAT_a-registry_milestone-asked";`,
|
|
`export function stepEndedSubject(node) {`, `export const milestoneAskedSubjectAll = "mesh.seat.a-registry.event.milestone-asked.*";`} {
|
|
if !strings.Contains(js, want) {
|
|
t.Errorf("index.js lacks %q:\n%s", want, js)
|
|
}
|
|
}
|
|
for _, want := range []string{`export declare const owedStream: "SEAT_a-registry_owed";`, `export declare function owedSubject(repository: string, number: string): string;`} {
|
|
if !strings.Contains(dts, want) {
|
|
t.Errorf("index.d.ts lacks %q:\n%s", want, dts)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBodiesAreTypesAndAcceptedKindsRideStreams is design 47 G §452, §454, §455 and §391's check for the generator: a
|
|
// definition's `bodies` yield their types in both languages and no sending code, a reference into `bodies` resolves
|
|
// as one into `defs`, an accepted kind on a stream has the subject `<stream>.<kind>` (`step` on both streams stays
|
|
// two), and a verb naming no class is refused by nothing.
|
|
func TestBodiesAreTypesAndAcceptedKindsRideStreams(t *testing.T) {
|
|
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files, err := Generate(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := string(files["go/seats/aregistry/contract.go"])
|
|
for _, want := range []string{
|
|
"type Finding struct", "Related []Finding", "type PeekAnswer = Finding",
|
|
`"check.step"`, `"delivery.step"`, `"check.run"`,
|
|
`const AcceptSubjectCheckStep = "mesh.seat.a-registry.accept.check"`,
|
|
`const AcceptSubjectDeliveryStep = "mesh.seat.a-registry.accept.delivery"`, // the queue alone: the kind is in the body (A-170)
|
|
"type CheckStepAccept struct", "type DeliveryStepAccept",
|
|
} {
|
|
if !strings.Contains(src, want) {
|
|
t.Errorf("the Go contract lacks %q:\n%s", want, src)
|
|
}
|
|
}
|
|
if strings.Contains(src, "SendFinding") || strings.Contains(src, "SubjectFinding") {
|
|
t.Error("a body yields more than its type")
|
|
}
|
|
dts := string(files["ts/seats/aregistry/index.d.ts"])
|
|
js := string(files["ts/seats/aregistry/index.js"])
|
|
for _, want := range []string{"export interface Finding", "checkStepSubject", "deliveryStepSubject"} {
|
|
if !strings.Contains(dts, want) {
|
|
t.Errorf("index.d.ts lacks %q:\n%s", want, dts)
|
|
}
|
|
}
|
|
if !strings.Contains(js, `deliveryStepSubject = "mesh.seat.a-registry.accept.delivery";`) {
|
|
t.Errorf("index.js lacks the delivery stream's subject:\n%s", js)
|
|
}
|
|
}
|