diff --git a/demos/sandbox/client/main.go b/demos/sandbox/client/main.go index 0ddbf9155..11269e024 100644 --- a/demos/sandbox/client/main.go +++ b/demos/sandbox/client/main.go @@ -185,7 +185,7 @@ func runCommand(ctx context.Context, atenetAddr string, actorRef resources.Actor return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Host = actorRef.DNSName() + req.Host = resources.ActorDNSName(actorRef) resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/e2e/router_client.go b/internal/e2e/router_client.go index 6951befd3..a98bf191c 100644 --- a/internal/e2e/router_client.go +++ b/internal/e2e/router_client.go @@ -93,6 +93,6 @@ func (c *RouterClient) request(ctx context.Context, method string, actorRef reso req.Header.Set("Content-Type", "application/json") } // The router routes on the Host/:authority, not a header. - req.Host = actorRef.DNSName() + req.Host = resources.ActorDNSName(actorRef) return c.http.Do(req) } diff --git a/internal/e2e/suites/demo/demo_test.go b/internal/e2e/suites/demo/demo_test.go index 5c2d2507b..e7a1ecd1d 100644 --- a/internal/e2e/suites/demo/demo_test.go +++ b/internal/e2e/suites/demo/demo_test.go @@ -1203,7 +1203,7 @@ func callActorPathOnce(t *testing.T, actorRef resources.ActorRef, method, path s if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - reqHttp.Host = actorRef.DNSName() + reqHttp.Host = resources.ActorDNSName(actorRef) httpClient := &http.Client{Timeout: 15 * time.Second} resp, err := httpClient.Do(reqHttp) diff --git a/internal/resources/actorref.go b/internal/resources/actorref.go deleted file mode 100644 index 0beabab26..000000000 --- a/internal/resources/actorref.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resources - -import ( - "fmt" - "log/slog" - "strings" - - "github.com/agent-substrate/substrate/pkg/proto/ateapipb" -) - -// ActorRef identifies an actor by the (atespace, name). -// -// ActorRef is the in-process form of the identity that ateapipb.ObjectRef -// carries on the wire. -type ActorRef struct { - // Atespace is the isolation boundary the actor was created into. Required. - Atespace string - // Name is the actor's name, unique within Atespace. Required. - Name string -} - -func (r ActorRef) String() string { - return r.Atespace + "/" + r.Name -} - -// LogValue implements slog.LogValuer so that slog.Any("actor", ref) records the -// two components as a group ("actor.atespace", "actor.name") rather than -// flattening them into one opaque string. -func (r ActorRef) LogValue() slog.Value { - return slog.GroupValue( - slog.String("atespace", r.Atespace), - slog.String("name", r.Name), - ) -} - -// DNSName returns the uniform DNS name the actor is reachable at. -// This is: "..actors.resources.substrate.ate.dev". -func (r ActorRef) DNSName() string { - return r.Name + "." + r.Atespace + "." + ActorDNSSuffix -} - -// ToObjectRef converts the reference to its wire form. -func (r ActorRef) ToObjectRef() *ateapipb.ObjectRef { - return &ateapipb.ObjectRef{Atespace: r.Atespace, Name: r.Name} -} - -// ActorRefFromObjectRef converts a wire reference to an ActorRef. -func ActorRefFromObjectRef(ref *ateapipb.ObjectRef) ActorRef { - return ActorRef{Atespace: ref.GetAtespace(), Name: ref.GetName()} -} - -// ActorRefFromActor returns the reference addressing the given actor. -func ActorRefFromActor(a *ateapipb.Actor) ActorRef { - return ActorRef{ - Atespace: a.GetMetadata().GetAtespace(), - Name: a.GetMetadata().GetName(), - } -} - -// ParseActorDNSName parses a DNS name for a given actor. -func ParseActorDNSName(name string) (ActorRef, error) { - rest, found := strings.CutSuffix(strings.TrimSuffix(name, "."), "."+ActorDNSSuffix) - if !found { - return ActorRef{}, fmt.Errorf("invalid actor DNS name: must end with %s, got %q", ActorDNSSuffix, name) - } - actorName, atespace, found := strings.Cut(rest, ".") - if !found { - return ActorRef{}, fmt.Errorf("invalid actor DNS name: expected ..%s, got %q", ActorDNSSuffix, name) - } - if !IsValidResourceName(actorName) { - return ActorRef{}, fmt.Errorf("invalid actor DNS name %q: %q is not a valid actor name", name, actorName) - } - if !IsValidResourceName(atespace) { - return ActorRef{}, fmt.Errorf("invalid actor DNS name %q: %q is not a valid atespace", name, atespace) - } - return ActorRef{Atespace: atespace, Name: actorName}, nil -} diff --git a/internal/resources/actorref_test.go b/internal/resources/actorref_test.go deleted file mode 100644 index 63b74069f..000000000 --- a/internal/resources/actorref_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resources - -import ( - "testing" - - "github.com/agent-substrate/substrate/pkg/proto/ateapipb" -) - -func TestActorRefString(t *testing.T) { - got := ActorRef{Atespace: "team-a", Name: "act-1"}.String() - if want := "team-a/act-1"; got != want { - t.Errorf("String() = %q, want %q", got, want) - } -} - -func TestActorRefDNSName(t *testing.T) { - actorRef := ActorRef{Atespace: "team-a", Name: "act-1"} - - got := actorRef.DNSName() - want := "act-1.team-a.actors.resources.substrate.ate.dev" - if got != want { - t.Errorf("DNSName() = %q, want %q", got, want) - } - - parsed, err := ParseActorDNSName(got) - if err != nil { - t.Fatalf("ParseActorDNSName(%q) error = %v", got, err) - } - if parsed != actorRef { - t.Errorf("round-trip = %+v, want %+v", parsed, actorRef) - } -} - -func TestParseActorDNSName(t *testing.T) { - tests := []struct { - name string - input string - want ActorRef - wantErr bool - }{ - {"valid", "act-1.team-a.actors.resources.substrate.ate.dev", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, - {"valid trailing dot", "act-1.team-a.actors.resources.substrate.ate.dev.", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, - {"wrong suffix", "act-1.team-a.example.com", ActorRef{}, true}, - {"missing atespace", "act-1.actors.resources.substrate.ate.dev", ActorRef{}, true}, - {"invalid actor name", "ACT-1.team-a.actors.resources.substrate.ate.dev", ActorRef{}, true}, - {"invalid atespace", "act-1.TEAM.actors.resources.substrate.ate.dev", ActorRef{}, true}, - {"host:port not accepted", "act-1.team-a.actors.resources.substrate.ate.dev:8080", ActorRef{}, true}, - {"empty", "", ActorRef{}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseActorDNSName(tt.input) - if (err != nil) != tt.wantErr { - t.Fatalf("ParseActorDNSName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - } - if got != tt.want { - t.Errorf("ParseActorDNSName(%q) = %+v, want %+v", tt.input, got, tt.want) - } - }) - } -} - -func TestActorRefObjectRefRoundTrip(t *testing.T) { - actorRef := ActorRef{Atespace: "team-a", Name: "act-1"} - - obj := actorRef.ToObjectRef() - if obj.GetAtespace() != "team-a" || obj.GetName() != "act-1" { - t.Errorf("ToObjectRef() = (%q, %q), want (team-a, act-1)", obj.GetAtespace(), obj.GetName()) - } - if got := ActorRefFromObjectRef(obj); got != actorRef { - t.Errorf("round-trip = %+v, want %+v", got, actorRef) - } -} - -func TestActorRefFromActor(t *testing.T) { - tests := []struct { - name string - actor *ateapipb.Actor - want ActorRef - }{ - { - name: "populated", - actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{ - Atespace: "team-a", - Name: "act-1", - }}, - want: ActorRef{Atespace: "team-a", Name: "act-1"}, - }, - {"nil actor", nil, ActorRef{}}, - {"nil metadata", &ateapipb.Actor{}, ActorRef{}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ActorRefFromActor(tt.actor); got != tt.want { - t.Errorf("ActorRefFromActor() = %+v, want %+v", got, tt.want) - } - }) - } -} diff --git a/internal/resources/actortemplateref.go b/internal/resources/actortemplateref.go deleted file mode 100644 index c3862a777..000000000 --- a/internal/resources/actortemplateref.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resources - -import ( - "log/slog" - - "github.com/agent-substrate/substrate/pkg/proto/ateapipb" -) - -// ActorTemplateRef identifies an ActorTemplate by the (atespace, name). -// -// ActorTemplateRef is the in-process form of the identity that -// ateapipb.ObjectRef carries on the wire. -type ActorTemplateRef struct { - // Atespace is the isolation boundary the template was created into. Required. - Atespace string - // Name is the template's name, unique within Atespace. Required. - Name string -} - -func (r ActorTemplateRef) String() string { - return r.Atespace + "/" + r.Name -} - -// LogValue implements slog.LogValuer so that slog.Any("template", ref) records -// the two components as a group ("template.atespace", "template.name") rather -// than flattening them into one opaque string. -func (r ActorTemplateRef) LogValue() slog.Value { - return slog.GroupValue( - slog.String("atespace", r.Atespace), - slog.String("name", r.Name), - ) -} - -// ToObjectRef converts the reference to its wire form. -func (r ActorTemplateRef) ToObjectRef() *ateapipb.ObjectRef { - return &ateapipb.ObjectRef{Atespace: r.Atespace, Name: r.Name} -} - -// ActorTemplateRefFromObjectRef converts a wire reference to an ActorTemplateRef. -func ActorTemplateRefFromObjectRef(ref *ateapipb.ObjectRef) ActorTemplateRef { - return ActorTemplateRef{Atespace: ref.GetAtespace(), Name: ref.GetName()} -} - -// ActorTemplateRefFromActorTemplate returns the reference addressing the given -// template. -func ActorTemplateRefFromActorTemplate(t *ateapipb.ActorTemplate) ActorTemplateRef { - return ActorTemplateRef{ - Atespace: t.GetMetadata().GetAtespace(), - Name: t.GetMetadata().GetName(), - } -} - -// ActorTemplateVersionRef identifies an ActorTemplateVersion by the -// (atespace, name). -// -// ActorTemplateVersionRef is the in-process form of the identity that -// ateapipb.ObjectRef carries on the wire. -type ActorTemplateVersionRef struct { - // Atespace is the isolation boundary the version was created into. Required. - Atespace string - // Name is the version's name, unique within Atespace. Required. - Name string -} - -func (r ActorTemplateVersionRef) String() string { - return r.Atespace + "/" + r.Name -} - -// LogValue implements slog.LogValuer so that slog.Any("version", ref) records -// the two components as a group ("version.atespace", "version.name") rather -// than flattening them into one opaque string. -func (r ActorTemplateVersionRef) LogValue() slog.Value { - return slog.GroupValue( - slog.String("atespace", r.Atespace), - slog.String("name", r.Name), - ) -} - -// ToObjectRef converts the reference to its wire form. -func (r ActorTemplateVersionRef) ToObjectRef() *ateapipb.ObjectRef { - return &ateapipb.ObjectRef{Atespace: r.Atespace, Name: r.Name} -} - -// ActorTemplateVersionRefFromObjectRef converts a wire reference to an -// ActorTemplateVersionRef. -func ActorTemplateVersionRefFromObjectRef(ref *ateapipb.ObjectRef) ActorTemplateVersionRef { - return ActorTemplateVersionRef{Atespace: ref.GetAtespace(), Name: ref.GetName()} -} - -// ActorTemplateVersionRefFromActorTemplateVersion returns the reference -// addressing the given version. -func ActorTemplateVersionRefFromActorTemplateVersion(v *ateapipb.ActorTemplateVersion) ActorTemplateVersionRef { - return ActorTemplateVersionRef{ - Atespace: v.GetMetadata().GetAtespace(), - Name: v.GetMetadata().GetName(), - } -} diff --git a/internal/resources/resourceref.go b/internal/resources/resourceref.go new file mode 100644 index 000000000..b4c69176d --- /dev/null +++ b/internal/resources/resourceref.go @@ -0,0 +1,137 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resources + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// Resource is any Atespaced resource message carrying the common metadata. +type Resource interface { + GetMetadata() *ateapipb.ResourceMetadata +} + +// ResourceRef identifies an Atespaced resource by the (atespace, name). +type ResourceRef[R Resource] struct { + // Atespace is the isolation boundary the resource was created into. Required. + Atespace string + // Name is the resource's name, unique within Atespace. Required. + Name string +} + +func (r ResourceRef[R]) String() string { + return r.Atespace + "/" + r.Name +} + +// LogValue implements slog.LogValuer so that slog.Any("template", ref) records +// the two components as a group ("template.atespace", "template.name") rather +// than flattening them into one opaque string. +func (r ResourceRef[R]) LogValue() slog.Value { + return slog.GroupValue( + slog.String("atespace", r.Atespace), + slog.String("name", r.Name), + ) +} + +// ToObjectRef converts the reference to its wire form. +func (r ResourceRef[R]) ToObjectRef() *ateapipb.ObjectRef { + return &ateapipb.ObjectRef{Atespace: r.Atespace, Name: r.Name} +} + +// resourceRefFromObjectRef converts a wire reference to the in-process form. +func resourceRefFromObjectRef[R Resource](ref *ateapipb.ObjectRef) ResourceRef[R] { + return ResourceRef[R]{Atespace: ref.GetAtespace(), Name: ref.GetName()} +} + +// ActorRef identifies an actor by the (atespace, name). +type ActorRef = ResourceRef[*ateapipb.Actor] + +// ActorRefFromObjectRef converts a wire reference to an ActorRef. +func ActorRefFromObjectRef(ref *ateapipb.ObjectRef) ActorRef { + return resourceRefFromObjectRef[*ateapipb.Actor](ref) +} + +// ActorRefFromActor returns the reference addressing the given actor. +func ActorRefFromActor(a *ateapipb.Actor) ActorRef { + return ActorRef{ + Atespace: a.GetMetadata().GetAtespace(), + Name: a.GetMetadata().GetName(), + } +} + +// ActorDNSName returns the uniform DNS name the actor is reachable at. +// This is: "..actors.resources.substrate.ate.dev". +func ActorDNSName(r ActorRef) string { + return r.Name + "." + r.Atespace + "." + ActorDNSSuffix +} + +// ParseActorDNSName parses a DNS name for a given actor. +func ParseActorDNSName(name string) (ActorRef, error) { + rest, found := strings.CutSuffix(strings.TrimSuffix(name, "."), "."+ActorDNSSuffix) + if !found { + return ActorRef{}, fmt.Errorf("invalid actor DNS name: must end with %s, got %q", ActorDNSSuffix, name) + } + actorName, atespace, found := strings.Cut(rest, ".") + if !found { + return ActorRef{}, fmt.Errorf("invalid actor DNS name: expected ..%s, got %q", ActorDNSSuffix, name) + } + if !IsValidResourceName(actorName) { + return ActorRef{}, fmt.Errorf("invalid actor DNS name %q: %q is not a valid actor name", name, actorName) + } + if !IsValidResourceName(atespace) { + return ActorRef{}, fmt.Errorf("invalid actor DNS name %q: %q is not a valid atespace", name, atespace) + } + return ActorRef{Atespace: atespace, Name: actorName}, nil +} + +// ActorTemplateRef identifies an ActorTemplate by the (atespace, name). +type ActorTemplateRef = ResourceRef[*ateapipb.ActorTemplate] + +// ActorTemplateRefFromObjectRef converts a wire reference to an ActorTemplateRef. +func ActorTemplateRefFromObjectRef(ref *ateapipb.ObjectRef) ActorTemplateRef { + return resourceRefFromObjectRef[*ateapipb.ActorTemplate](ref) +} + +// ActorTemplateRefFromActorTemplate returns the reference addressing the given +// template. +func ActorTemplateRefFromActorTemplate(t *ateapipb.ActorTemplate) ActorTemplateRef { + return ActorTemplateRef{ + Atespace: t.GetMetadata().GetAtespace(), + Name: t.GetMetadata().GetName(), + } +} + +// ActorTemplateVersionRef identifies an ActorTemplateVersion by the +// (atespace, name). +type ActorTemplateVersionRef = ResourceRef[*ateapipb.ActorTemplateVersion] + +// ActorTemplateVersionRefFromObjectRef converts a wire reference to an +// ActorTemplateVersionRef. +func ActorTemplateVersionRefFromObjectRef(ref *ateapipb.ObjectRef) ActorTemplateVersionRef { + return resourceRefFromObjectRef[*ateapipb.ActorTemplateVersion](ref) +} + +// ActorTemplateVersionRefFromActorTemplateVersion returns the reference +// addressing the given version. +func ActorTemplateVersionRefFromActorTemplateVersion(v *ateapipb.ActorTemplateVersion) ActorTemplateVersionRef { + return ActorTemplateVersionRef{ + Atespace: v.GetMetadata().GetAtespace(), + Name: v.GetMetadata().GetName(), + } +} diff --git a/internal/resources/actortemplateref_test.go b/internal/resources/resourceref_test.go similarity index 50% rename from internal/resources/actortemplateref_test.go rename to internal/resources/resourceref_test.go index 20470484d..c6e290bb1 100644 --- a/internal/resources/actortemplateref_test.go +++ b/internal/resources/resourceref_test.go @@ -15,11 +15,122 @@ package resources import ( + "reflect" "testing" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" ) +// The ref aliases must stay distinct types: the phantom ResourceRef kind is +// what stops a reference to one resource kind from being passed where another +// is expected. +func TestRefAliasesAreDistinctTypes(t *testing.T) { + types := map[string]reflect.Type{ + "ActorRef": reflect.TypeFor[ActorRef](), + "ActorTemplateRef": reflect.TypeFor[ActorTemplateRef](), + "ActorTemplateVersionRef": reflect.TypeFor[ActorTemplateVersionRef](), + } + seen := make(map[reflect.Type]string) + for name, typ := range types { + if other, dup := seen[typ]; dup { + t.Errorf("%s and %s are the same type; the phantom kind marker was lost", name, other) + } + seen[typ] = name + } +} + +func TestActorRefString(t *testing.T) { + got := ActorRef{Atespace: "team-a", Name: "act-1"}.String() + if want := "team-a/act-1"; got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} + +func TestActorRefDNSName(t *testing.T) { + actorRef := ActorRef{Atespace: "team-a", Name: "act-1"} + + got := ActorDNSName(actorRef) + want := "act-1.team-a.actors.resources.substrate.ate.dev" + if got != want { + t.Errorf("ActorDNSName() = %q, want %q", got, want) + } + + parsed, err := ParseActorDNSName(got) + if err != nil { + t.Fatalf("ParseActorDNSName(%q) error = %v", got, err) + } + if parsed != actorRef { + t.Errorf("round-trip = %+v, want %+v", parsed, actorRef) + } +} + +func TestParseActorDNSName(t *testing.T) { + tests := []struct { + name string + input string + want ActorRef + wantErr bool + }{ + {"valid", "act-1.team-a.actors.resources.substrate.ate.dev", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, + {"valid trailing dot", "act-1.team-a.actors.resources.substrate.ate.dev.", ActorRef{Atespace: "team-a", Name: "act-1"}, false}, + {"wrong suffix", "act-1.team-a.example.com", ActorRef{}, true}, + {"missing atespace", "act-1.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"invalid actor name", "ACT-1.team-a.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"invalid atespace", "act-1.TEAM.actors.resources.substrate.ate.dev", ActorRef{}, true}, + {"host:port not accepted", "act-1.team-a.actors.resources.substrate.ate.dev:8080", ActorRef{}, true}, + {"empty", "", ActorRef{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseActorDNSName(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseActorDNSName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("ParseActorDNSName(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestActorRefObjectRefRoundTrip(t *testing.T) { + actorRef := ActorRef{Atespace: "team-a", Name: "act-1"} + + obj := actorRef.ToObjectRef() + if obj.GetAtespace() != "team-a" || obj.GetName() != "act-1" { + t.Errorf("ToObjectRef() = (%q, %q), want (team-a, act-1)", obj.GetAtespace(), obj.GetName()) + } + if got := ActorRefFromObjectRef(obj); got != actorRef { + t.Errorf("round-trip = %+v, want %+v", got, actorRef) + } +} + +func TestActorRefFromActor(t *testing.T) { + tests := []struct { + name string + actor *ateapipb.Actor + want ActorRef + }{ + { + name: "populated", + actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{ + Atespace: "team-a", + Name: "act-1", + }}, + want: ActorRef{Atespace: "team-a", Name: "act-1"}, + }, + {"nil actor", nil, ActorRef{}}, + {"nil metadata", &ateapipb.Actor{}, ActorRef{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ActorRefFromActor(tt.actor); got != tt.want { + t.Errorf("ActorRefFromActor() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestActorTemplateRefString(t *testing.T) { got := ActorTemplateRef{Atespace: "team-a", Name: "tmpl-1"}.String() if want := "team-a/tmpl-1"; got != want {