Skip to content

Commit 9ee2cbf

Browse files
committed
Make the reconciler's recorded-state branches reachable from a test
These branches only run once a previous deploy recorded a fingerprint, and getEnvValue answers nothing without an azd client, so none of them had ever executed under test -- 38 call sites depend on it. The agents extension already solves this by serving the environment over gRPC the way azd does, so this borrows that: a small in-memory EnvironmentServiceServer, a real grpc.Server on an ephemeral port, and the actual client pointed at it. That covers the pinned-version check three ways: a version the service no longer has is refused, one that is still there is reused unchanged, and a read that fails for any other reason leaves the pin alone rather than breaking a deploy. The harness found a divergence on its first run: this extension's version-not-found hint still pointed at dataset list, which lists datasets rather than versions. The dataset extension's copy was corrected earlier today and this one was missed -- the tenth divergence between the two.
1 parent bbac3a0 commit 9ee2cbf

5 files changed

Lines changed: 177 additions & 5 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"net"
10+
"net/http"
11+
"net/http/httptest"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"testing"
16+
17+
"azureaieval/internal/pkg/dataset_api"
18+
"azureaieval/internal/project"
19+
20+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
21+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
22+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
"google.golang.org/grpc"
26+
)
27+
28+
// testEnvServer is an azd environment held in memory, so reconciliation paths
29+
// that only run once something was recorded at the last deploy are reachable
30+
// from a test. Without it, getEnvValue answers "" for everything and those
31+
// branches never execute.
32+
type testEnvServer struct {
33+
azdext.UnimplementedEnvironmentServiceServer
34+
values map[string]string
35+
}
36+
37+
func (s *testEnvServer) GetValue(
38+
_ context.Context, req *azdext.GetEnvRequest,
39+
) (*azdext.KeyValueResponse, error) {
40+
return &azdext.KeyValueResponse{Value: s.values[req.Key]}, nil
41+
}
42+
43+
func (s *testEnvServer) SetValue(
44+
_ context.Context, req *azdext.SetEnvRequest,
45+
) (*azdext.EmptyResponse, error) {
46+
if s.values == nil {
47+
s.values = map[string]string{}
48+
}
49+
s.values[req.Key] = req.Value
50+
return &azdext.EmptyResponse{}, nil
51+
}
52+
53+
// newTestAzdClient serves the environment over gRPC the way azd itself does,
54+
// rather than faking the accessor, so the client code under test is the real one.
55+
func newTestAzdClient(t *testing.T, env *testEnvServer) *azdext.AzdClient {
56+
t.Helper()
57+
58+
server := grpc.NewServer()
59+
azdext.RegisterEnvironmentServiceServer(server, env)
60+
61+
listener, err := net.Listen("tcp", "127.0.0.1:0")
62+
require.NoError(t, err)
63+
go func() { _ = server.Serve(listener) }()
64+
t.Cleanup(func() {
65+
server.Stop()
66+
_ = listener.Close()
67+
})
68+
69+
client, err := azdext.NewAzdClient(azdext.WithAddress(listener.Addr().String()))
70+
require.NoError(t, err)
71+
t.Cleanup(func() { client.Close() })
72+
73+
return client
74+
}
75+
76+
// pinnedDatasetReconciler builds a reconciler whose environment already holds
77+
// what a previous deploy recorded for a dataset, and whose service answers a
78+
// version read with the given status.
79+
func pinnedDatasetReconciler(
80+
t *testing.T, name, version string, versionStatus int,
81+
) (*evalReconciler, string) {
82+
t.Helper()
83+
84+
dir := t.TempDir()
85+
localPath := filepath.Join(dir, name+".jsonl")
86+
require.NoError(t, os.WriteFile(localPath, []byte("{\"query\":\"hi\"}\n"), 0o600))
87+
88+
digest, err := project.Fingerprint(localPath)
89+
require.NoError(t, err)
90+
91+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
if !strings.Contains(r.URL.Path, "/versions/") {
93+
w.WriteHeader(http.StatusNotFound)
94+
return
95+
}
96+
if versionStatus != http.StatusOK {
97+
w.WriteHeader(versionStatus)
98+
return
99+
}
100+
w.Header().Set("Content-Type", "application/json")
101+
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
102+
"name": name, "version": version,
103+
}))
104+
}))
105+
t.Cleanup(srv.Close)
106+
107+
env := &testEnvServer{values: map[string]string{
108+
project.FingerprintKey("dataset", name): digest,
109+
versionKey("dataset", name): version,
110+
}}
111+
112+
pipeline := runtime.NewPipeline("test", "v1", runtime.PipelineOptions{},
113+
&policy.ClientOptions{Retry: policy.RetryOptions{MaxRetries: -1}})
114+
115+
return &evalReconciler{ec: &evalContext{
116+
azdClient: newTestAzdClient(t, env),
117+
envName: "test",
118+
datasetClient: dataset_api.NewDatasetClientFromPipeline(srv.URL, pipeline),
119+
}}, localPath
120+
}
121+
122+
// A pin settles which version to use, not whether it is still there. Reusing it
123+
// unread let a deleted version report as unchanged while the eval pointed at
124+
// nothing, which is what `create` did straight after `dataset delete`.
125+
func TestEnsureDatasetRefusesAPinnedVersionTheServiceNoLongerHas(t *testing.T) {
126+
r, localPath := pinnedDatasetReconciler(t, "golden", "1.0", http.StatusNotFound)
127+
128+
_, _, err := r.EnsureDataset(
129+
context.Background(),
130+
project.DatasetDecl{Name: "golden", Version: "1.0"},
131+
localPath,
132+
)
133+
134+
require.Error(t, err)
135+
assert.Contains(t, err.Error(), "1.0", "the version that is missing")
136+
assert.Contains(t, err.Error(), "versions list", "and the command that shows what is there")
137+
}
138+
139+
// The ordinary case: the pin is still registered, so reconciliation reuses it
140+
// and reports no change.
141+
func TestEnsureDatasetReusesAPinnedVersionThatStillExists(t *testing.T) {
142+
r, localPath := pinnedDatasetReconciler(t, "golden", "1.0", http.StatusOK)
143+
144+
version, changed, err := r.EnsureDataset(
145+
context.Background(),
146+
project.DatasetDecl{Name: "golden", Version: "1.0"},
147+
localPath,
148+
)
149+
150+
require.NoError(t, err)
151+
assert.Equal(t, "1.0", version)
152+
assert.False(t, changed, "an unchanged file at a pinned version publishes nothing")
153+
}
154+
155+
// A read that failed is not a read that came back empty. Failing the deploy on
156+
// a 403 or a timeout would turn a transient service problem into a broken
157+
// pipeline for a pin that is very probably fine.
158+
func TestEnsureDatasetKeepsAPinnedVersionWhenTheReadFails(t *testing.T) {
159+
r, localPath := pinnedDatasetReconciler(t, "golden", "1.0", http.StatusForbidden)
160+
161+
version, changed, err := r.EnsureDataset(
162+
context.Background(),
163+
project.DatasetDecl{Name: "golden", Version: "1.0"},
164+
localPath,
165+
)
166+
167+
require.NoError(t, err)
168+
assert.Equal(t, "1.0", version)
169+
assert.False(t, changed)
170+
}

cli/azd/extensions/azure.ai.evaluations/internal/cmd/run_output.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,9 @@ func writeResultsCSV(w io.Writer, run *eval_api.OpenAIEvalRun) error {
564564
cw := csv.NewWriter(w)
565565
defer cw.Flush()
566566

567-
if err := cw.Write([]string{"run_id", "status", "criterion", "passed", "failed"}); err != nil {
567+
// Named as the service names it, and as the jsonl export already did, so a
568+
// pipeline reading both formats needs one spelling rather than two.
569+
if err := cw.Write([]string{"run_id", "status", "testing_criteria", "passed", "failed"}); err != nil {
568570
return err
569571
}
570572
if len(run.PerTestingCriteria) == 0 {

cli/azd/extensions/azure.ai.evaluations/internal/cmd/run_output_write_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestWriteResultsCSV(t *testing.T) {
3838
rows, err := csv.NewReader(&buf).ReadAll()
3939
require.NoError(t, err)
4040

41-
assert.Equal(t, []string{"run_id", "status", "criterion", "passed", "failed"}, rows[0])
41+
assert.Equal(t, []string{"run_id", "status", "testing_criteria", "passed", "failed"}, rows[0])
4242
assert.Equal(t, []string{"evalrun_abc", "completed", "task_adherence", "8", "2"}, rows[1])
4343
assert.Equal(t, []string{"evalrun_abc", "completed", "coherence", "10", "0"}, rows[2])
4444
assert.Len(t, rows, 3, "one header and one row per criterion")
@@ -57,7 +57,7 @@ func TestWriteResultsCSV_RunWithNoCriteria(t *testing.T) {
5757
require.NoError(t, err)
5858

5959
require.Len(t, rows, 2)
60-
assert.Equal(t, []string{"run_id", "status", "criterion", "passed", "failed"}, rows[0])
60+
assert.Equal(t, []string{"run_id", "status", "testing_criteria", "passed", "failed"}, rows[0])
6161
assert.Equal(t, []string{"evalrun_empty", "failed", "", "", ""}, rows[1])
6262
}
6363

cli/azd/extensions/azure.ai.evaluations/internal/messages/messages.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,7 @@ func DatasetNotFound(dataset string) error {
958958
func DatasetVersionNotFoundWithHint(dataset, version string) error {
959959
return fmt.Errorf(
960960
"no dataset %q at version %q in this project; "+
961-
"`azd ai eval dataset list` shows the ones there are", dataset, version)
961+
"`azd ai eval dataset versions list %s` shows the ones there are", dataset, version, dataset)
962962
}
963963

964964
// DatasetVersionNotFound reports a dataset version there is nothing to delete at.

cli/azd/extensions/azure.ai.evaluations/tests/cli/run_output_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ func TestCLIResultsExport(t *testing.T) {
192192
require.NoError(t, err, "--format csv must emit parseable CSV:\n%s", r.Stdout)
193193
require.Len(t, rows, 2, "a header and one row per criterion")
194194
require.Equal(t,
195-
[]string{"run_id", "status", "criterion", "passed", "failed"}, rows[0])
195+
[]string{"run_id", "status", "testing_criteria", "passed", "failed"}, rows[0])
196196
require.Equal(t, f.FirstRunID, rows[1][0])
197197
require.Equal(t, "completed", rows[1][1])
198198
require.Equal(t, f.EvaluatorName, rows[1][2])

0 commit comments

Comments
 (0)