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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ notte functions show --function-id <id> # View specific function details (diffe
notte functions create --file workflow.py --response-format @schema.json # ... with its response documented
notte functions update --file workflow.py # Update current function code
notte functions update --file workflow.py --response-format @schema.json # ... and re-document its response
notte functions configure --self-healing --instructions "..." # Set self-healing and its instructions
notte functions configure --run-instructions "..." --self-healing # Set usage notes and self-healing
notte functions rollback --version <version> # Restore an earlier version (see `versions` in show)
notte functions health # Runtime health: Python version, installed packages, reachability
notte functions delete # Delete current function
Expand All @@ -217,6 +217,20 @@ notte functions schedule --cron "0 12 ? * * *" # Schedule current function (six
notte functions unschedule # Remove schedule from current function
```

### Personas, Profiles and Usage

```bash
notte personas update --persona-id <id> --name "checkout tester" # Rename a persona
notte profiles cookies --profile-id <id> # Read a profile's cookies
notte profiles cookies-set --profile-id <id> --file cookies.json # Import cookies into a profile
notte usage logs [--endpoint /sessions/start] [--page N] # List API requests made with your key
```

`profiles cookies-set` takes either a bare array of cookies — what Playwright's
`storageState` and the browser extensions export — or an object with a `cookies`
key. Add `--source-format chrome` if they came from Chrome, and `--mode append`
to add to the profile's cookies rather than replace them.

`--response-format` takes a JSON Schema describing what `run()` returns, as
inline JSON, `@file.json`, or `-` for stdin. The API never derives it, so a
function created without it has no documented response — which is what the
Expand All @@ -228,7 +242,19 @@ python -c 'import json, typing, client; print(json.dumps(typing.get_type_hints(c
notte functions create --file client.py --response-format @schema.json
```

`configure` sends only the flags you pass, so setting `--instructions` leaves
`--run-instructions` is documentation for whoever *calls* the function — how long a
run takes, what each variable is for, which sites it trips over:

```bash
notte functions configure --run-instructions "Takes ~3 min, so call it async. \
Hits a captcha on the login page every few runs. \
\`query\` is the search term; \`max_items\` caps the results."
```

It is not input to the self-healing agent, which is the separate
`--self-healing` flag.

`configure` sends only the flags you pass, so setting `--run-instructions` leaves
self-healing untouched. Disable self-healing with `--self-healing=false`: the
API treats an absent field as "leave it alone" rather than "off". Note that it
can only be enabled on functions an agent built — a CLI-created function has no
Expand Down
214 changes: 214 additions & 0 deletions internal/cmd/coveragegaps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package cmd

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/nottelabs/notte-cli/internal/testutil"
)

func setupCoverageTest(t *testing.T) *testutil.MockServer {
t.Helper()
env := testutil.SetupTestEnv(t)
env.SetEnv("NOTTE_API_KEY", "test-key")

server := testutil.NewMockServer()
t.Cleanup(func() { server.Close() })
env.SetEnv("NOTTE_API_URL", server.URL())

origFormat := outputFormat
outputFormat = "json"
t.Cleanup(func() { outputFormat = origFormat })

return server
}

func TestPersonaUpdate_SendsTheNewName(t *testing.T) {
server := setupCoverageTest(t)
server.AddResponse("/personas/"+"p_1", 200, `{"persona_id":"p_1","status":"active"}`)

origID := personaID
personaID = "p_1"
t.Cleanup(func() { personaID = origID; PersonaUpdateName = "" })

cmd := &cobra.Command{}
RegisterPersonaUpdateFlags(cmd)
cmd.SetContext(context.Background())
if err := cmd.Flags().Set("name", "checkout tester"); err != nil {
t.Fatalf("setting --name: %v", err)
}

testutil.CaptureOutput(func() {
if err := runPersonaUpdate(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

requests := server.Requests("/personas/p_1")
if len(requests) != 1 {
t.Fatalf("got %d requests, want 1", len(requests))
}
if requests[0].Method != "PATCH" {
t.Errorf("method = %s, want PATCH", requests[0].Method)
}
var body map[string]any
if err := json.Unmarshal([]byte(requests[0].Body), &body); err != nil {
t.Fatalf("parsing body: %v", err)
}
if body["name"] != "checkout tester" {
t.Errorf("name = %v", body["name"])
}
}

func TestProfileCookies_ReadsTheProfile(t *testing.T) {
server := setupCoverageTest(t)
server.AddResponse("/profiles/"+profileIDTest+"/cookies", 200, `{"cookies":[{"name":"a","value":"b"}]}`)

origID := profileID
profileID = profileIDTest
t.Cleanup(func() { profileID = origID })

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

stdout, _ := testutil.CaptureOutput(func() {
if err := runProfileCookies(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if !strings.Contains(stdout, "cookies") {
t.Errorf("expected the cookies in the output, got %q", stdout)
}
}

// Playwright's storageState and the browser extensions export a bare array,
// while the API wants it under a `cookies` key. Making the caller reshape their
// own export first would be a papercut for no reason.
func TestProfileCookiesSet_AcceptsBothFileShapes(t *testing.T) {
for _, tc := range []struct {
name string
content string
}{
{name: "bare array", content: `[{"name":"a","value":"b","domain":"example.com","path":"/"}]`},
{name: "wrapped object", content: `{"cookies":[{"name":"a","value":"b","domain":"example.com","path":"/"}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
server := setupCoverageTest(t)
server.AddResponse("/profiles/"+profileIDTest+"/cookies", 200,
`{"success":true,"message":"ok","cookies_count":1,"mode":"replace"}`)

path := filepath.Join(t.TempDir(), "cookies.json")
if err := os.WriteFile(path, []byte(tc.content), 0o600); err != nil {
t.Fatalf("writing cookies file: %v", err)
}

origID, origFile := profileID, profileCookiesFile
profileID, profileCookiesFile = profileIDTest, path
t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile })

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

testutil.CaptureOutput(func() {
if err := runProfileCookiesSet(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

requests := server.Requests("/profiles/" + profileIDTest + "/cookies")
if len(requests) != 1 {
t.Fatalf("got %d requests, want 1", len(requests))
}
var body map[string]any
if err := json.Unmarshal([]byte(requests[0].Body), &body); err != nil {
t.Fatalf("parsing body: %v", err)
}
cookies, ok := body["cookies"].([]any)
if !ok || len(cookies) != 1 {
t.Fatalf("expected one cookie under `cookies`, got %v", body)
}
// Neither optional field was passed, so neither should be sent.
if _, present := body["source_format"]; present {
t.Error("source_format was sent without --source-format")
}
if _, present := body["mode"]; present {
t.Error("mode was sent without --mode")
}
})
}
}

// Both shapes have to refuse an empty set. `--mode` defaults to replace, so
// sending zero cookies empties the profile - a destructive result from what is
// almost always a bad export.
func TestProfileCookiesSet_RejectsAFileWithNoCookies(t *testing.T) {
for _, content := range []string{`{"notCookies":1}`, `[]`, `{"cookies":[]}`, ` `} {
t.Run(content, func(t *testing.T) {
server := setupCoverageTest(t)

path := filepath.Join(t.TempDir(), "cookies.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("writing file: %v", err)
}

origID, origFile := profileID, profileCookiesFile
profileID, profileCookiesFile = profileIDTest, path
t.Cleanup(func() { profileID, profileCookiesFile = origID, origFile })

cmd := &cobra.Command{}
cmd.SetContext(context.Background())

err := runProfileCookiesSet(cmd, nil)
if err == nil {
t.Fatal("expected an error rather than a request that empties the profile")
}
if len(server.Requests("/profiles/"+profileIDTest+"/cookies")) != 0 {
t.Error("an empty cookie set still reached the API")
}
})
}
}

// The filters are sent only when asked, so the API keeps owning its defaults.
func TestUsageLogs_SendsOnlyTheFiltersPassed(t *testing.T) {
server := setupCoverageTest(t)
server.AddResponse("/usage/logs", 200,
`{"items":[{"endpoint":"/sessions/start"}],"page":1,"page_size":10,"has_next":false,"has_previous":false}`)

origEndpoint := usageLogsEndpoint
usageLogsEndpoint = "/sessions/start"
t.Cleanup(func() { usageLogsEndpoint = origEndpoint })

cmd := &cobra.Command{}
registerPaginationFlags(cmd)
cmd.Flags().Bool("only-current-token", false, "")
cmd.Flags().Bool("include-system", false, "")
cmd.SetContext(context.Background())

testutil.CaptureOutput(func() {
if err := runUsageLogs(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

requests := server.Requests("/usage/logs")
if len(requests) != 1 {
t.Fatalf("got %d requests, want 1", len(requests))
}
query := requests[0].Query
if !strings.Contains(query, "endpoint=") {
t.Errorf("expected the endpoint filter in %q", query)
}
for _, unwanted := range []string{"only_current_token", "include_system", "only_active"} {
if strings.Contains(query, unwanted) {
t.Errorf("%s was sent without being asked for: %q", unwanted, query)
}
}
}
8 changes: 4 additions & 4 deletions internal/cmd/functionconfigure_flags.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 12 additions & 8 deletions internal/cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,13 @@ var functionsUpdateCmd = &cobra.Command{

var functionsConfigureCmd = &cobra.Command{
Use: "configure",
Short: "Set self-healing and its instructions",
Long: "Update a function's metadata. Only the flags you pass are sent, so " +
"configuring instructions leaves self-healing as it was, and vice versa.",
Short: "Set usage notes and self-healing",
Long: "Update a function's metadata.\n\n" +
"--run-instructions is documentation for whoever calls the function - how long a " +
"run takes, what each variable is for, which sites it trips over. It is not " +
"input to the self-healing agent.\n\n" +
"Only the flags you pass are sent, so setting instructions leaves self-healing " +
"as it was, and vice versa.",
Args: cobra.NoArgs,
RunE: runFunctionConfigure,
}
Expand Down Expand Up @@ -499,14 +503,14 @@ func runFunctionConfigure(cmd *cobra.Command, args []string) error {

// An empty PATCH is accepted by the API and changes nothing, which reads as
// success for a command that did not do what the caller meant.
if !cmd.Flags().Changed("instructions") && !cmd.Flags().Changed("self-healing") {
return errors.New("nothing to configure: pass --instructions, --self-healing, or both")
if !cmd.Flags().Changed("run-instructions") && !cmd.Flags().Changed("self-healing") {
return errors.New("nothing to configure: pass --run-instructions, --self-healing, or both")
}
// `--instructions ""` is refused rather than sent. The generated builder
// `--run-instructions ""` is refused rather than sent. The generated builder
// omits an empty string, so it would otherwise travel as far as an empty
// PATCH: accepted, 200, nothing changed, and the caller told it worked.
if cmd.Flags().Changed("instructions") && FunctionConfigureInstructions == "" {
return errors.New("--instructions cannot be empty")
if cmd.Flags().Changed("run-instructions") && FunctionConfigureInstructions == "" {
return errors.New("--run-instructions cannot be empty")
}

client, err := GetClient()
Expand Down
14 changes: 7 additions & 7 deletions internal/cmd/functionsextra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func TestFunctionConfigure_SendsOnlyTheFlagsPassed(t *testing.T) {
t.Cleanup(func() { outputFormat = origFormat })

cmd := configureCmd(t)
if err := cmd.Flags().Set("instructions", "retry the login step"); err != nil {
t.Fatalf("setting --instructions: %v", err)
if err := cmd.Flags().Set("run-instructions", "retry the login step"); err != nil {
t.Fatalf("setting --run-instructions: %v", err)
}

testutil.CaptureOutput(func() {
Expand Down Expand Up @@ -123,26 +123,26 @@ func TestFunctionConfigure_RefusesToSendNothing(t *testing.T) {
}

// The generated builder sends an optional string only when it is non-empty, so
// `--instructions ""` would reach the API as an empty PATCH: 200, nothing
// `--run-instructions ""` would reach the API as an empty PATCH: 200, nothing
// changed, and the caller told it worked. Refused up front instead.
func TestFunctionConfigure_RejectsEmptyInstructions(t *testing.T) {
server := setupFunctionTest(t)
server.AddResponse("/functions/"+functionIDTest, 200, functionJSON())

cmd := configureCmd(t)
if err := cmd.Flags().Set("instructions", ""); err != nil {
t.Fatalf("setting --instructions: %v", err)
if err := cmd.Flags().Set("run-instructions", ""); err != nil {
t.Fatalf("setting --run-instructions: %v", err)
}

err := runFunctionConfigure(cmd, nil)
if err == nil {
t.Fatal("expected an error for --instructions \"\"")
t.Fatal("expected an error for --run-instructions \"\"")
}
if !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
if len(server.Requests("/functions/"+functionIDTest)) != 0 {
t.Error("an empty --instructions still reached the API")
t.Error("an empty --run-instructions still reached the API")
}
}

Expand Down
Loading
Loading