From a1e745ec902886f9d9fa411f19b1548fd7c6db1a Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 24 Apr 2026 08:45:19 +0800 Subject: [PATCH 1/2] chore(tests): update model versions in Anthropic tests and add Test command to SUI - Updated testConnectorID comment and model version in TestAnthropicStreamRetry to reflect the latest model (Claude Haiku 4.5). - Added Test command to the SUI command set with associated flags for improved testing capabilities. --- .../llm/providers/anthropic/anthropic_test.go | 4 +- cmd/root.go | 1 + cmd/sui/sui.go | 8 + cmd/sui/test.go | 91 ++++ sui/test/context.go | 206 ++++++++ sui/test/helpers_test.go | 110 +++++ sui/test/runner.go | 456 ++++++++++++++++++ sui/test/runner_test.go | 362 ++++++++++++++ sui/test/types.go | 110 +++++ 9 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 cmd/sui/test.go create mode 100644 sui/test/context.go create mode 100644 sui/test/helpers_test.go create mode 100644 sui/test/runner.go create mode 100644 sui/test/runner_test.go create mode 100644 sui/test/types.go diff --git a/agent/llm/providers/anthropic/anthropic_test.go b/agent/llm/providers/anthropic/anthropic_test.go index 64551f5a..db9b7e7a 100644 --- a/agent/llm/providers/anthropic/anthropic_test.go +++ b/agent/llm/providers/anthropic/anthropic_test.go @@ -16,7 +16,7 @@ import ( "github.com/yaoapp/yao/test" ) -// testConnectorID uses the cheapest model (Claude Haiku 3.5) to save tokens +// testConnectorID uses the cheapest model (Claude Haiku 4.5) to save tokens const testConnectorID = "claude.haiku-3_0" // TestAnthropicStreamBasic tests basic streaming completion with Anthropic API @@ -214,7 +214,7 @@ func TestAnthropicStreamRetry(t *testing.T) { connDSL := `{ "type": "anthropic", "options": { - "model": "claude-3-5-haiku-20241022", + "model": "claude-haiku-4-5-20251001", "key": "sk-ant-invalid-key-should-fail" } }` diff --git a/cmd/root.go b/cmd/root.go index 7b09cf68..da4b604f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -187,6 +187,7 @@ func init() { suiCmd.AddCommand(sui.WatchCmd) suiCmd.AddCommand(sui.BuildCmd) suiCmd.AddCommand(sui.TransCmd) + suiCmd.AddCommand(sui.TestCmd) // Agent agentCmd.AddCommand(agent.TestCmd) diff --git a/cmd/sui/sui.go b/cmd/sui/sui.go index c5a73aee..82187529 100644 --- a/cmd/sui/sui.go +++ b/cmd/sui/sui.go @@ -11,4 +11,12 @@ func init() { TransCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data")) TransCmd.PersistentFlags().BoolVarP(&debug, "debug", "D", false, L("Debug mode")) TransCmd.PersistentFlags().StringVarP(&locales, "locales", "l", "", L("Locales, separated by commas")) + + TestCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data")) + TestCmd.Flags().StringVar(&testPage, "page", "", L("Filter by page route (substring match)")) + TestCmd.Flags().StringVar(&testRun, "run", "", L("Filter test functions by regex")) + TestCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, L("Verbose output")) + TestCmd.Flags().BoolVar(&testJSON, "json", false, L("Output report in JSON format")) + TestCmd.Flags().BoolVar(&testFailFast, "fail-fast", false, L("Stop on first failure")) + TestCmd.Flags().StringVar(&testTimeout, "timeout", "30s", L("Timeout per test")) } diff --git a/cmd/sui/test.go b/cmd/sui/test.go new file mode 100644 index 00000000..39a6db07 --- /dev/null +++ b/cmd/sui/test.go @@ -0,0 +1,91 @@ +package sui + +import ( + "fmt" + "os" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/engine" + suitest "github.com/yaoapp/yao/sui/test" +) + +var ( + testPage string + testRun string + testVerbose bool + testJSON bool + testFailFast bool + testTimeout string +) + +// TestCmd runs SUI backend tests +var TestCmd = &cobra.Command{ + Use: "test", + Short: L("Test SUI backend scripts"), + Long: L("Run unit tests for SUI backend scripts (*.backend_test.ts)"), + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, color.RedString(L("Usage: yao sui test [template]"))) + os.Exit(1) + } + + Boot() + + cfg := config.Conf + _, err := engine.Load(cfg, engine.LoadOption{Action: "sui.test"}) + if err != nil { + fmt.Fprintln(os.Stderr, color.RedString(err.Error())) + os.Exit(1) + } + + suiID := args[0] + template := "default" + if len(args) >= 2 { + template = args[1] + } + if suiID == "agent" && template == "default" { + template = "agent" + } + + timeout := 30 * time.Second + if testTimeout != "" { + d, err := time.ParseDuration(testTimeout) + if err != nil { + fmt.Fprintln(os.Stderr, color.RedString("Invalid --timeout: %s", testTimeout)) + os.Exit(1) + } + timeout = d + } + + opts := &suitest.Options{ + SUIID: suiID, + Template: template, + Page: testPage, + Run: testRun, + Data: data, + Verbose: testVerbose, + JSON: testJSON, + FailFast: testFailFast, + Timeout: timeout, + } + + runner, err := suitest.NewRunner(opts) + if err != nil { + fmt.Fprintln(os.Stderr, color.RedString(err.Error())) + os.Exit(1) + } + + report, err := runner.Run() + if err != nil { + fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err.Error())) + os.Exit(1) + } + + if report.HasFailures() { + os.Exit(1) + } + }, +} diff --git a/sui/test/context.go b/sui/test/context.go new file mode 100644 index 00000000..e72247ac --- /dev/null +++ b/sui/test/context.go @@ -0,0 +1,206 @@ +package test + +import ( + "fmt" + + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/yao/sui/core" + "rogchap.com/v8go" +) + +// SUITestContext provides the test execution context for SUI backend scripts. +// It wraps a loaded Script and a mock Request, exposing call/callWithRequest +// to JS test functions. +type SUITestContext struct { + Script *core.Script + Request *core.Request + Prefix string + Sid string +} + +// NewSUITestContext creates a new SUI test context for a page +func NewSUITestContext(script *core.Script, prefix string, sid string) *SUITestContext { + return &SUITestContext{ + Script: script, + Prefix: prefix, + Sid: sid, + Request: &core.Request{ + Method: "GET", + Sid: sid, + Payload: map[string]interface{}{}, + Params: map[string]string{}, + }, + } +} + +// Call invokes an Api-prefixed method on the backend script (sui.Run path). +// The JS function is looked up as (e.g. "ApiGetDashboard"). +func (ctx *SUITestContext) Call(method string, args ...interface{}) (interface{}, error) { + scriptCtx, err := ctx.Script.NewContext(ctx.Sid, nil) + if err != nil { + return nil, err + } + defer scriptCtx.Close() + + if ctx.Request.Authorized != nil { + scriptCtx.WithAuthorized(ctx.Request.Authorized) + } + + fnName := ctx.Prefix + method + global := scriptCtx.Global() + if !global.Has(fnName) { + return nil, fmt.Errorf("method %s not found (looked for %s)", method, fnName) + } + + return scriptCtx.Call(fnName, args...) +} + +// CallWithRequest invokes a method on the backend script, appending *Request +// as the last argument (page-render @Method path). +func (ctx *SUITestContext) CallWithRequest(method string, args ...interface{}) (interface{}, error) { + return ctx.Script.Call(ctx.Request, method, args...) +} + +// NewSUITestContextObject creates a JavaScript object exposing the SUITestContext to V8 +func NewSUITestContextObject(v8ctx *v8go.Context, ctx *SUITestContext) (*v8go.Value, error) { + iso := v8ctx.Isolate() + + tmpl := v8go.NewObjectTemplate(iso) + tmpl.Set("call", ctx.callMethod(iso, v8ctx)) + tmpl.Set("callWithRequest", ctx.callWithRequestMethod(iso, v8ctx)) + tmpl.Set("setAuthorized", ctx.setAuthorizedMethod(iso, v8ctx)) + tmpl.Set("reset", ctx.resetMethod(iso)) + + instance, err := tmpl.NewInstance(v8ctx) + if err != nil { + return nil, err + } + + obj, err := instance.Value.AsObject() + if err != nil { + return nil, err + } + + reqObj, err := ctx.buildRequestObject(v8ctx) + if err != nil { + return nil, err + } + obj.Set("request", reqObj) + + return instance.Value, nil +} + +// callMethod implements ctx.call(method, ...args) in JS +func (ctx *SUITestContext) callMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + jsArgs := info.Args() + if len(jsArgs) < 1 { + throwJSError(v8ctx, "call requires at least 1 argument (method name)") + return v8go.Undefined(iso) + } + + method := jsArgs[0].String() + goArgs := make([]interface{}, 0, len(jsArgs)-1) + for _, arg := range jsArgs[1:] { + val, err := bridge.GoValue(arg, v8ctx) + if err != nil { + goArgs = append(goArgs, arg.String()) + continue + } + goArgs = append(goArgs, val) + } + + result, err := ctx.Call(method, goArgs...) + if err != nil { + return bridge.JsException(v8ctx, err) + } + + jsVal, err := bridge.JsValue(v8ctx, result) + if err != nil { + return bridge.JsException(v8ctx, err) + } + return jsVal + }) +} + +// callWithRequestMethod implements ctx.callWithRequest(method, ...args) in JS +func (ctx *SUITestContext) callWithRequestMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + jsArgs := info.Args() + if len(jsArgs) < 1 { + throwJSError(v8ctx, "callWithRequest requires at least 1 argument (method name)") + return v8go.Undefined(iso) + } + + method := jsArgs[0].String() + goArgs := make([]interface{}, 0, len(jsArgs)-1) + for _, arg := range jsArgs[1:] { + val, err := bridge.GoValue(arg, v8ctx) + if err != nil { + goArgs = append(goArgs, arg.String()) + continue + } + goArgs = append(goArgs, val) + } + + result, err := ctx.CallWithRequest(method, goArgs...) + if err != nil { + return bridge.JsException(v8ctx, err) + } + + jsVal, err := bridge.JsValue(v8ctx, result) + if err != nil { + return bridge.JsException(v8ctx, err) + } + return jsVal + }) +} + +// setAuthorizedMethod implements ctx.setAuthorized(auth) in JS +func (ctx *SUITestContext) setAuthorizedMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + jsArgs := info.Args() + if len(jsArgs) < 1 { + return v8go.Undefined(iso) + } + + val, err := bridge.GoValue(jsArgs[0], v8ctx) + if err != nil { + return v8go.Undefined(iso) + } + + if authMap, ok := val.(map[string]interface{}); ok { + ctx.Request.Authorized = authMap + } + return v8go.Undefined(iso) + }) +} + +// resetMethod implements ctx.reset() in JS — clears authorized, payload, params +func (ctx *SUITestContext) resetMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx.Request.Authorized = nil + ctx.Request.Payload = map[string]interface{}{} + ctx.Request.Params = map[string]string{} + ctx.Request.Query = nil + ctx.Request.Headers = nil + ctx.Request.Body = nil + return v8go.Undefined(iso) + }) +} + +// buildRequestObject creates a JS object representing ctx.request +func (ctx *SUITestContext) buildRequestObject(v8ctx *v8go.Context) (*v8go.Value, error) { + reqMap := map[string]interface{}{ + "sid": ctx.Request.Sid, + "method": ctx.Request.Method, + "payload": ctx.Request.Payload, + "params": ctx.Request.Params, + "authorized": ctx.Request.Authorized, + } + return bridge.JsValue(v8ctx, reqMap) +} + +func throwJSError(v8ctx *v8go.Context, msg string) { + bridge.JsException(v8ctx, fmt.Errorf("%s", msg)) +} diff --git a/sui/test/helpers_test.go b/sui/test/helpers_test.go new file mode 100644 index 00000000..f873a1bd --- /dev/null +++ b/sui/test/helpers_test.go @@ -0,0 +1,110 @@ +package test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/engine" + "github.com/yaoapp/yao/sui/core" + yaotest "github.com/yaoapp/yao/test" +) + +func prepareInternal(t *testing.T) { + t.Helper() + yaotest.Prepare(t, config.Conf) + _, err := engine.Load(config.Conf, engine.LoadOption{Action: "sui.test"}) + require.NoError(t, err) +} + +func TestExtractFuncName(t *testing.T) { + cases := []struct { + line string + want string + }{ + {"export function TestFoo(t, ctx) {", "TestFoo"}, + {"function TestBar(t, ctx) {", "TestBar"}, + {"export function TestBaz() {", "TestBaz"}, + {"// function TestComment(t) {", ""}, + {"const x = 1", ""}, + {"function notTest(t) {", "notTest"}, + {"export function (t) {", ""}, + {"function ", ""}, + {"function TestNoParens", ""}, + } + for _, tt := range cases { + got := extractFuncName(tt.line) + assert.Equal(t, tt.want, got, "extractFuncName(%q)", tt.line) + } +} + +func TestFilterTestsInvalidRegex(t *testing.T) { + tc := []*TestCase{{Name: "TestFoo"}} + _, err := filterTests(tc, "[invalid") + assert.Error(t, err) +} + +func TestFilterTestsNoMatch(t *testing.T) { + tc := []*TestCase{{Name: "TestFoo"}, {Name: "TestBar"}} + filtered, err := filterTests(tc, "^TestZzz$") + assert.NoError(t, err) + assert.Empty(t, filtered) +} + +func TestFilterTestsPartialMatch(t *testing.T) { + tc := []*TestCase{{Name: "TestFoo"}, {Name: "TestBar"}, {Name: "TestFooBar"}} + filtered, err := filterTests(tc, "Foo") + assert.NoError(t, err) + assert.Len(t, filtered, 2) +} + +func TestExecuteTestScriptNotLoaded(t *testing.T) { + r := &Runner{opts: &Options{Timeout: 30 * time.Second}} + tc := &TestCase{Name: "TestX", Function: "TestX"} + + result := r.executeTest(tc, nil, "nonexistent-script-id", "Api", "test-sid") + assert.Equal(t, "error", result.Status) + assert.Contains(t, result.Error, "not loaded") +} + +func TestExecuteTestFunctionNotFound(t *testing.T) { + prepareInternal(t) + defer yaotest.Clean() + + _, has := core.SUIs["agent"] + if !has { + t.Skip("no agent SUI") + } + + // Load the errors test script + testFile := "assistants/tests/sui-pages/pages/errors/errors.backend_test.ts" + testScriptID := "sui-test.helpers-coverage" + _, err := v8.Load(testFile, testScriptID) + require.NoError(t, err) + + backendPath := "assistants/tests/sui-pages/pages/errors/errors" + script, err := core.LoadScript(backendPath, true) + require.NoError(t, err) + require.NotNil(t, script) + + r := &Runner{opts: &Options{Timeout: 30 * time.Second}} + + // Call with a function name that doesn't exist in the script + tc := &TestCase{Name: "NonExistent", Function: "NonExistentFunction"} + result := r.executeTest(tc, script, testScriptID, "Api", "test-sid") + assert.Equal(t, "error", result.Status) + assert.Contains(t, result.Error, "is not a function") +} + +func TestLoadPageConfigBadJSON(t *testing.T) { + prepareInternal(t) + defer yaotest.Clean() + + // dashboard.html exists but is not valid JSON + cfg, err := LoadPageConfig("assistants/tests/sui-pages/pages/dashboard/dashboard.html") + assert.Error(t, err) + assert.Nil(t, cfg) +} diff --git a/sui/test/runner.go b/sui/test/runner.go new file mode 100644 index 00000000..d5da7ee4 --- /dev/null +++ b/sui/test/runner.go @@ -0,0 +1,456 @@ +package test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/yaoapp/gou/application" + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + agenttest "github.com/yaoapp/yao/agent/test" + "github.com/yaoapp/yao/sui/core" +) + +// Runner discovers and executes SUI backend tests +type Runner struct { + opts *Options + sui core.SUI + tmpl core.ITemplate +} + +// NewRunner creates a new SUI test runner +func NewRunner(opts *Options) (*Runner, error) { + sui, has := core.SUIs[opts.SUIID] + if !has { + return nil, fmt.Errorf("SUI %q not found", opts.SUIID) + } + + sid := uuid.New().String() + sui.WithSid(sid) + + tmpl, err := sui.GetTemplate(opts.Template) + if err != nil { + return nil, fmt.Errorf("template %q: %w", opts.Template, err) + } + + return &Runner{opts: opts, sui: sui, tmpl: tmpl}, nil +} + +// Run discovers and executes all matching backend tests, returning a report +func (r *Runner) Run() (*Report, error) { + startTime := time.Now() + sid := r.sui.GetSid() + + if !r.opts.JSON { + r.printHeader(sid) + } + + pageInfos, err := r.discoverPageTests() + if err != nil { + return nil, err + } + + if !r.opts.JSON { + fmt.Printf("Found: %d test files\n\n", len(pageInfos)) + } + + report := &Report{ + Type: "sui_backend_test", + SUIID: r.opts.SUIID, + Template: r.opts.Template, + Summary: &TestSummary{}, + Pages: make([]*PageReport, 0, len(pageInfos)), + Metadata: &TestMetadata{StartedAt: startTime}, + } + + for _, pi := range pageInfos { + pageReport, stop := r.runPageTests(pi, sid, report.Summary) + if pageReport != nil { + report.Pages = append(report.Pages, pageReport) + } + if stop { + break + } + } + + report.Summary.DurationMs = time.Since(startTime).Milliseconds() + report.Metadata.CompletedAt = time.Now() + + if r.opts.JSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(report) + } else { + r.printSummary(report.Summary, time.Since(startTime)) + } + + return report, nil +} + +// discoverPageTests walks template pages and finds those with backend_test.ts files +func (r *Runner) discoverPageTests() ([]*PageTestInfo, error) { + pages, err := r.tmpl.Pages() + if err != nil { + return nil, fmt.Errorf("listing pages: %w", err) + } + + var infos []*PageTestInfo + for _, page := range pages { + pg := page.Get() + if pg == nil { + continue + } + + if r.opts.Page != "" && !strings.Contains(pg.Route, r.opts.Page) { + continue + } + + dir := pg.Path + testFile := filepath.Join(dir, pg.Name+".backend_test.ts") + + exists, _ := application.App.Exists(testFile) + if !exists { + testFile = filepath.Join(dir, pg.Name+".backend_test.js") + exists, _ = application.App.Exists(testFile) + } + if !exists { + continue + } + + backendFile := filepath.Join(dir, pg.Name+".backend.ts") + if ex, _ := application.App.Exists(backendFile); !ex { + backendFile = filepath.Join(dir, pg.Name+".backend.js") + } + + prefix := "Api" + cfgFile := filepath.Join(dir, pg.Name+".config") + cfg, _ := LoadPageConfig(cfgFile) + if cfg == nil { + cfgFile = filepath.Join(dir, pg.Name+".cfg") + cfg, _ = LoadPageConfig(cfgFile) + } + if cfg != nil && cfg.API != nil && cfg.API.Prefix != "" { + prefix = cfg.API.Prefix + } + + infos = append(infos, &PageTestInfo{ + Route: pg.Route, + Name: pg.Name, + BackendFile: backendFile, + TestFile: testFile, + Prefix: prefix, + }) + } + + return infos, nil +} + +// discoverTests scans a test file for exported Test* functions +func discoverTests(testFile string) ([]*TestCase, error) { + content, err := application.App.Read(testFile) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", testFile, err) + } + + var tests []*TestCase + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, "function Test") { + continue + } + name := extractFuncName(line) + if name != "" && strings.HasPrefix(name, "Test") { + tests = append(tests, &TestCase{Name: name, Function: name}) + } + } + return tests, nil +} + +func extractFuncName(line string) string { + line = strings.TrimPrefix(line, "export ") + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "function ") { + return "" + } + line = strings.TrimPrefix(line, "function ") + idx := strings.Index(line, "(") + if idx == -1 { + return "" + } + return strings.TrimSpace(line[:idx]) +} + +// filterTests applies --run regex filter +func filterTests(tests []*TestCase, pattern string) ([]*TestCase, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + var filtered []*TestCase + for _, tc := range tests { + if re.MatchString(tc.Name) { + filtered = append(filtered, tc) + } + } + return filtered, nil +} + +// runPageTests loads and executes all tests for one page +func (r *Runner) runPageTests(pi *PageTestInfo, sid string, summary *TestSummary) (*PageReport, bool) { + tests, err := discoverTests(pi.TestFile) + if err != nil { + if !r.opts.JSON { + color.Red(" Error discovering tests in %s: %v\n", pi.TestFile, err) + } + return nil, false + } + + if r.opts.Run != "" { + tests, err = filterTests(tests, r.opts.Run) + if err != nil { + if !r.opts.JSON { + color.Red(" Invalid --run pattern: %v\n", err) + } + return nil, false + } + } + + if len(tests) == 0 { + return nil, false + } + + if !r.opts.JSON { + color.New(color.FgWhite, color.Bold).Printf("--- %s ---\n", pi.Route) + } + + // The backend script is loaded via core.LoadScript which finds *.backend.ts + // from the page .sui path. We derive the .sui path from the backend file path. + suiPath := strings.TrimSuffix(strings.TrimSuffix(pi.BackendFile, ".backend.ts"), ".backend.js") + script, err := core.LoadScript(suiPath, true) + if err != nil { + if !r.opts.JSON { + color.Red(" Failed to load backend script: %v\n", err) + } + return nil, false + } + if script == nil { + if !r.opts.JSON { + color.Yellow(" No backend script found for %s\n", pi.Route) + } + return nil, false + } + + // Load the test file into V8 + testScriptID := "sui-test." + strings.ReplaceAll(strings.Trim(pi.Route, "/"), "/", ".") + _, err = v8.Load(pi.TestFile, testScriptID) + if err != nil { + if !r.opts.JSON { + color.Red(" Failed to load test script: %v\n", err) + } + return nil, false + } + + // Only count tests after scripts are successfully loaded + summary.Total += len(tests) + + pageReport := &PageReport{Route: pi.Route, Results: make([]*TestResult, 0, len(tests))} + stop := false + + for _, tc := range tests { + result := r.executeTest(tc, script, testScriptID, pi.Prefix, sid) + pageReport.Results = append(pageReport.Results, result) + + switch result.Status { + case "passed": + summary.Passed++ + case "failed", "error": + summary.Failed++ + case "skipped": + summary.Skipped++ + } + + if !r.opts.JSON { + r.printTestResult(tc.Name, result) + } + + if r.opts.FailFast && (result.Status == "failed" || result.Status == "error") { + stop = true + break + } + } + + if !r.opts.JSON { + fmt.Println() + } + + return pageReport, stop +} + +// executeTest runs a single Test* function in V8 +func (r *Runner) executeTest(tc *TestCase, script *core.Script, testScriptID, prefix, sid string) (result *TestResult) { + startTime := time.Now() + result = &TestResult{Name: tc.Name, Status: "passed"} + + defer func() { + if rec := recover(); rec != nil { + result.Status = "error" + result.Error = fmt.Sprintf("panic: %v", rec) + } + result.DurationMs = time.Since(startTime).Milliseconds() + }() + + testScript, ok := v8.Scripts[testScriptID] + if !ok { + result.Status = "error" + result.Error = fmt.Sprintf("test script %q not loaded", testScriptID) + return + } + + scriptCtx, err := testScript.NewContext(sid, nil) + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("create context: %v", err) + return + } + defer scriptCtx.Close() + + v8ctx := scriptCtx.Context + + // Set share data for Process calls within tests + err = bridge.SetShareData(v8ctx, v8ctx.Global(), &bridge.Share{ + Sid: sid, + Root: false, + Global: nil, + }) + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("set share data: %v", err) + return + } + + testingT := agenttest.NewTestingT(tc.Name) + tObj, err := agenttest.NewTestingTObject(v8ctx, testingT) + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("create testing.T: %v", err) + return + } + + suiCtx := NewSUITestContext(script, prefix, sid) + ctxObj, err := NewSUITestContextObject(v8ctx, suiCtx) + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("create SUIContext: %v", err) + return + } + + global := v8ctx.Global() + fnValue, err := global.Get(tc.Function) + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("get function %s: %v", tc.Function, err) + return + } + + if !fnValue.IsFunction() { + result.Status = "error" + result.Error = fmt.Sprintf("%s is not a function", tc.Function) + return + } + + fn, err := fnValue.AsFunction() + if err != nil { + result.Status = "error" + result.Error = fmt.Sprintf("as function: %v", err) + return + } + + _, err = fn.Call(global, tObj, ctxObj) + if err != nil { + if testingT.Failed() { + goto collectResult + } + result.Status = "error" + result.Error = fmt.Sprintf("call error: %v", err) + return + } + +collectResult: + result.Logs = testingT.Logs() + + if testingT.Skipped() { + result.Status = "skipped" + return + } + + if testingT.Failed() { + result.Status = "failed" + errors := testingT.Errors() + if len(errors) > 0 { + result.Error = errors[0] + } + if info := testingT.AssertionInfo(); info != nil { + result.Assertion = &AssertionInfo{ + Type: info.Type, + Expected: info.Expected, + Actual: info.Actual, + Message: info.Message, + } + } + return + } + + return +} + +func (r *Runner) printHeader(sid string) { + fmt.Println(color.WhiteString("-----------------------")) + fmt.Println(color.WhiteString("SUI Backend Test")) + fmt.Printf(color.WhiteString(" SUI: %s\n"), r.opts.SUIID) + fmt.Printf(color.WhiteString(" Template: %s\n"), r.opts.Template) + if r.opts.Data != "" { + fmt.Printf(color.WhiteString(" Session: %s\n"), r.opts.Data) + } + fmt.Println(color.WhiteString("-----------------------")) +} + +func (r *Runner) printTestResult(name string, result *TestResult) { + switch result.Status { + case "passed": + color.Green(" %-40s PASS (%dms)\n", name, result.DurationMs) + case "failed": + color.Red(" %-40s FAIL (%dms)\n", name, result.DurationMs) + if result.Error != "" { + color.Red(" %s\n", result.Error) + } + case "error": + color.Red(" %-40s ERROR (%dms)\n", name, result.DurationMs) + if result.Error != "" { + color.Red(" %s\n", result.Error) + } + case "skipped": + color.Yellow(" %-40s SKIP (%dms)\n", name, result.DurationMs) + } +} + +func (r *Runner) printSummary(s *TestSummary, elapsed time.Duration) { + passColor := color.New(color.FgGreen) + failColor := color.New(color.FgRed) + + fmt.Print("RESULTS: ") + passColor.Printf("%d passed", s.Passed) + fmt.Print(", ") + if s.Failed > 0 { + failColor.Printf("%d failed", s.Failed) + } else { + fmt.Printf("%d failed", s.Failed) + } + fmt.Printf(", %d skipped (%s)\n", s.Skipped, elapsed.Truncate(time.Millisecond)) +} diff --git a/sui/test/runner_test.go b/sui/test/runner_test.go new file mode 100644 index 00000000..00470b96 --- /dev/null +++ b/sui/test/runner_test.go @@ -0,0 +1,362 @@ +package test_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/engine" + "github.com/yaoapp/yao/sui/core" + suitest "github.com/yaoapp/yao/sui/test" + "github.com/yaoapp/yao/test" +) + +func prepare(t *testing.T) { + t.Helper() + test.Prepare(t, config.Conf) + _, err := engine.Load(config.Conf, engine.LoadOption{Action: "sui.test"}) + require.NoError(t, err) +} + +func requireAgentSUI(t *testing.T) { + t.Helper() + _, has := core.SUIs["agent"] + if !has { + t.Skip("no 'agent' SUI loaded") + } +} + +// --- happy path (dashboard page: 4 pass) --- + +func TestDiscoverPageTests(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + require.NotNil(t, report) + + assert.Greater(t, report.Summary.Total, 0) + assert.Greater(t, len(report.Pages), 0) +} + +func TestRunnerExecuteTests(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + + assert.Equal(t, "sui_backend_test", report.Type) + assert.False(t, report.Metadata.StartedAt.IsZero()) + assert.False(t, report.Metadata.CompletedAt.IsZero()) + assert.Equal(t, 4, report.Summary.Total) + assert.Equal(t, 4, report.Summary.Passed) + assert.Equal(t, 0, report.Summary.Failed) + assert.False(t, report.HasFailures()) +} + +func TestRunnerWithRunFilter(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + Run: "TestGetDashboard", + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 1, report.Summary.Total) + assert.Equal(t, 1, report.Summary.Passed) +} + +func TestRunnerFailFast(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + FailFast: true, + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 0, report.Summary.Failed) +} + +// --- errors page: covers fail, skip, setAuthorized, reset --- + +func TestRunnerWithFailAndSkip(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/errors", + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + require.NotNil(t, report) + + assert.Equal(t, 6, report.Summary.Total) + assert.Equal(t, 2, report.Summary.Failed, "TestAlwaysFail + TestRuntimeError should fail") + assert.Equal(t, 1, report.Summary.Skipped, "TestAlwaysSkip should be skipped") + assert.Equal(t, 3, report.Summary.Passed) + assert.True(t, report.HasFailures()) + + page := report.Pages[0] + for _, r := range page.Results { + switch r.Name { + case "TestAlwaysFail": + assert.Equal(t, "failed", r.Status) + assert.NotEmpty(t, r.Error) + case "TestAlwaysSkip": + assert.Equal(t, "skipped", r.Status) + case "TestSetAuthorizedAndReset": + assert.Equal(t, "passed", r.Status) + case "TestCallWithRequestRender": + assert.Equal(t, "passed", r.Status) + case "TestCallNonExistentMethod": + assert.Equal(t, "passed", r.Status) + case "TestRuntimeError": + assert.Equal(t, "error", r.Status) + assert.Contains(t, r.Error, "deliberate runtime error") + } + } +} + +func TestRunnerFailFastStopsOnError(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/errors", + FailFast: true, + JSON: true, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 1, report.Summary.Failed) + // fail-fast: executed only up to the first failure + executed := report.Summary.Passed + report.Summary.Failed + report.Summary.Skipped + assert.Less(t, executed, report.Summary.Total, "fail-fast should stop before running all tests") +} + +// --- verbose (non-JSON) output: covers printHeader, printTestResult, printSummary --- + +func TestRunnerVerboseAllPass(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + Verbose: true, + JSON: false, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 4, report.Summary.Passed) +} + +func TestRunnerVerboseWithFailures(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/errors", + Verbose: true, + JSON: false, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Greater(t, report.Summary.Failed, 0) +} + +func TestRunnerVerboseWithData(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + opts := &suitest.Options{ + SUIID: "agent", + Template: "agent", + Page: "tests.sui-pages/dashboard", + Data: `{"key":"value"}`, + Verbose: true, + JSON: false, + } + + runner, err := suitest.NewRunner(opts) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 4, report.Summary.Passed) +} + +// --- error paths --- + +func TestNewRunnerInvalidSUI(t *testing.T) { + prepare(t) + defer test.Clean() + + _, err := suitest.NewRunner(&suitest.Options{SUIID: "nonexistent", Template: "default"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestNewRunnerInvalidTemplate(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + _, err := suitest.NewRunner(&suitest.Options{SUIID: "agent", Template: "nonexistent-tmpl"}) + assert.Error(t, err) +} + +func TestRunnerNoMatchingPage(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + runner, err := suitest.NewRunner(&suitest.Options{ + SUIID: "agent", Template: "agent", Page: "xyz-no-match", JSON: true, + }) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 0, report.Summary.Total) +} + +func TestRunnerNoMatchingRun(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + runner, err := suitest.NewRunner(&suitest.Options{ + SUIID: "agent", Template: "agent", Page: "tests.sui-pages/dashboard", + Run: "NoSuchFunction", JSON: true, + }) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 0, report.Summary.Total) +} + +func TestRunnerVerboseNoTestFiles(t *testing.T) { + prepare(t) + defer test.Clean() + requireAgentSUI(t) + + runner, err := suitest.NewRunner(&suitest.Options{ + SUIID: "agent", Template: "agent", Page: "xyz-no-match", JSON: false, + }) + require.NoError(t, err) + + report, err := runner.Run() + require.NoError(t, err) + assert.Equal(t, 0, report.Summary.Total) +} + +// --- types --- + +func TestLoadPageConfig(t *testing.T) { + prepare(t) + defer test.Clean() + + cfgFile := "assistants/tests/sui-pages/pages/dashboard/dashboard.config" + exists, _ := application.App.Exists(cfgFile) + if !exists { + t.Skipf("test config %s not found", cfgFile) + } + + cfg, err := suitest.LoadPageConfig(cfgFile) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, "Test Dashboard", cfg.Title) +} + +func TestLoadPageConfigNotExist(t *testing.T) { + prepare(t) + defer test.Clean() + + cfg, err := suitest.LoadPageConfig("nonexistent/path.config") + assert.NoError(t, err) + assert.Nil(t, cfg) +} + +func TestReportHasFailures(t *testing.T) { + r := &suitest.Report{Summary: &suitest.TestSummary{Failed: 1}} + assert.True(t, r.HasFailures()) + r.Summary.Failed = 0 + assert.False(t, r.HasFailures()) +} diff --git a/sui/test/types.go b/sui/test/types.go new file mode 100644 index 00000000..4d4f6954 --- /dev/null +++ b/sui/test/types.go @@ -0,0 +1,110 @@ +package test + +import ( + "time" + + jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/sui/core" +) + +// Options holds configuration for a SUI backend test run +type Options struct { + SUIID string `json:"sui_id"` + Template string `json:"template"` + Page string `json:"page,omitempty"` + Run string `json:"run,omitempty"` + Data string `json:"data,omitempty"` + Verbose bool `json:"verbose,omitempty"` + JSON bool `json:"json,omitempty"` + FailFast bool `json:"fail_fast,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` +} + +// PageTestInfo describes a page that has backend tests +type PageTestInfo struct { + Route string `json:"route"` + Name string `json:"name"` + BackendFile string `json:"backend_file"` + TestFile string `json:"test_file"` + PageConfigFile string `json:"page_config_file,omitempty"` + Prefix string `json:"prefix"` +} + +// TestCase represents a single test function discovered in a backend_test.ts file +type TestCase struct { + Name string `json:"name"` + Function string `json:"function"` +} + +// TestResult represents the outcome of a single test function +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` + Assertion *AssertionInfo `json:"assertion,omitempty"` + Logs []string `json:"logs,omitempty"` +} + +// AssertionInfo contains details about an assertion failure +type AssertionInfo struct { + Type string `json:"type"` + Expected interface{} `json:"expected,omitempty"` + Actual interface{} `json:"actual,omitempty"` + Message string `json:"message,omitempty"` +} + +// TestSummary contains aggregated statistics +type TestSummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + DurationMs int64 `json:"duration_ms"` +} + +// Report represents the complete test report for yao sui test +type Report struct { + Type string `json:"type"` + SUIID string `json:"sui_id"` + Template string `json:"template"` + Summary *TestSummary `json:"summary"` + Pages []*PageReport `json:"pages"` + Metadata *TestMetadata `json:"metadata"` +} + +// PageReport contains results for a single page +type PageReport struct { + Route string `json:"route"` + Results []*TestResult `json:"results"` +} + +// TestMetadata contains metadata about the test run +type TestMetadata struct { + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` +} + +// HasFailures returns true if any tests failed +func (r *Report) HasFailures() bool { + return r.Summary.Failed > 0 +} + +// LoadPageConfig reads and parses a .cfg file for a SUI page +func LoadPageConfig(file string) (*core.PageConfig, error) { + if exist, _ := application.App.Exists(file); !exist { + return nil, nil + } + + source, err := application.App.Read(file) + if err != nil { + return nil, err + } + + cfg := core.PageConfig{} + if err := jsoniter.Unmarshal(source, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} From 105c3aae5b4350f0576277128c8500ea4ed889b7 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 24 Apr 2026 09:11:29 +0800 Subject: [PATCH 2/2] fix(workflows): streamline release process for Linux and macOS - Removed the workflow_dispatch trigger from both release workflows to enforce tag-based releases. - Added a mechanism to wait for draft releases before uploading assets, ensuring all necessary files are present. - Implemented asset upload to GitHub releases and conditional publishing based on asset completeness. - Enhanced SHA256 checksum generation for both production and development binaries. --- .github/workflows/create-release.yml | 25 ++++++++++++++ .github/workflows/release-linux.yml | 50 +++++++++++++++++++++++----- .github/workflows/release-macos.yml | 44 +++++++++++++++++++----- 3 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/create-release.yml diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..2d772141 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,25 @@ +name: Create Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + create: + runs-on: ubuntu-latest + steps: + - name: Create Draft Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Yao v${VERSION}" \ + --generate-notes \ + --draft diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 6c868e7f..0bfa35f4 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,7 +1,6 @@ name: Release Linux on: - workflow_dispatch: push: tags: - "v*" @@ -174,16 +173,49 @@ jobs: cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev" cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev" chmod +x release/yao-* + + for ARCH in amd64 arm64; do + sha256sum "release/yao-${VERSION}-linux-${ARCH}" | awk '{print $1}' > "release/yao-linux-${ARCH}-prod.sha256" + sha256sum "release/yao-${VERSION}-linux-${ARCH}-dev" | awk '{print $1}' > "release/yao-linux-${ARCH}-dev.sha256" + done ls -lh release/ - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: Yao v${{ steps.version.outputs.version }} - files: release/* - generate_release_notes: true - make_latest: false + - name: Wait for Draft Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + for i in $(seq 1 30); do + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then + echo "Draft release found for $TAG." + exit 0 + fi + echo "Waiting for draft release... ($i/30)" + sleep 10 + done + echo "::error::Timed out waiting for draft release $TAG" + exit 1 + + - name: Upload Assets to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber + + - name: Publish Release if Complete + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length') + echo "Current assets: $ASSET_COUNT / 16" + if [ "$ASSET_COUNT" -ge 16 ]; then + echo "All assets present, publishing release..." + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + else + echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish." + fi - name: Upload Linux binaries to R2 env: diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index c7843705..566e2578 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -1,7 +1,6 @@ name: Release macOS on: - workflow_dispatch: push: tags: - "v*" @@ -280,13 +279,42 @@ jobs: chmod +x release/yao-* ls -lh release/ - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: Yao v${{ steps.version.outputs.version }} - files: release/* - generate_release_notes: true + - name: Wait for Draft Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + for i in $(seq 1 30); do + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then + echo "Draft release found for $TAG." + exit 0 + fi + echo "Waiting for draft release... ($i/30)" + sleep 10 + done + echo "::error::Timed out waiting for draft release $TAG" + exit 1 + + - name: Upload Assets to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber + + - name: Publish Release if Complete + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.version.outputs.tag }}" + ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length') + echo "Current assets: $ASSET_COUNT / 16" + if [ "$ASSET_COUNT" -ge 16 ]; then + echo "All assets present, publishing release..." + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + else + echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish." + fi - name: Upload macOS binaries to R2 env: