From dfcd040a1652cf8ddd71c919a33aa7f838b253e5 Mon Sep 17 00:00:00 2001 From: "Raphael (manual office deploy after cloud-state fix)" Date: Thu, 20 Aug 2026 12:27:16 -0700 Subject: [PATCH] Make headless render navigation deterministic Encode automation URLs and poll from fresh page contexts so transient redirects cannot abort or silently strand SVG generation. --- cmd/mdl/main.go | 105 ++++++++++++++++++++++++++++++++----------- cmd/mdl/main_test.go | 48 ++++++++++++++++++++ 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/cmd/mdl/main.go b/cmd/mdl/main.go index 4287fa98..191ef227 100644 --- a/cmd/mdl/main.go +++ b/cmd/mdl/main.go @@ -3,10 +3,12 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "net" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -18,6 +20,7 @@ import ( "goa.design/model/mdl" model "goa.design/model/pkg" + "github.com/chromedp/cdproto" "github.com/chromedp/chromedp" ) @@ -42,23 +45,22 @@ type ( browserAutomationResult struct { Status string `json:"status"` Error string `json:"error"` + URL string `json:"url"` } + browserAutomationEvaluator func(context.Context, *browserAutomationResult) error + // SliceFlag implements flag.Value for repeated string flags. SliceFlag []string ) const ( - browserAutomationReadyScript = `(() => { - const status = document.documentElement.dataset.mdlAutomationStatus; - return status === "complete" || status === "error"; -})()` - browserAutomationResultScript = `(() => { const root = document.documentElement; return { status: root.dataset.mdlAutomationStatus || "", error: root.dataset.mdlAutomationError || "", + url: document.location.href, }; })()` ) @@ -403,21 +405,13 @@ func renderViewsHeadless(baseURL, outDir string, views []string, cfg config) err return err } for _, key := range views { - // Build URL with automation params - q := fmt.Sprintf("?id=%s&auto=1&save=1", key) - if direction != "" { - q += "&direction=" + direction - } - if cfg.compact { - q += "&compact=1" - } - url := baseURL + "/" + q + renderURL := browserAutomationURL(baseURL, key, direction, cfg.compact) // Remove any existing file to ensure fresh wait svgPath := filepath.Join(outDir, key+".svg") _ = os.Remove(svgPath) - if err := run(url, svgPath, cfg.timeout); err != nil { + if err := run(renderURL, svgPath, cfg.timeout); err != nil { return fmt.Errorf("render %s: %w", key, err) } fmt.Println("Saved:", svgPath) @@ -438,6 +432,22 @@ func normalizeLayoutDirection(direction string) (string, error) { } } +// browserAutomationURL builds an encoded editor URL for one headless render. +func browserAutomationURL(baseURL, key, direction string, compact bool) string { + query := url.Values{ + "id": {key}, + "auto": {"1"}, + "save": {"1"}, + } + if direction != "" { + query.Set("direction", direction) + } + if compact { + query.Set("compact", "1") + } + return baseURL + "/?" + query.Encode() +} + // navigateExec abstracts browser navigation and automation result handling. type navigateExec func(url string, svgPath string, timeout time.Duration) error @@ -474,17 +484,11 @@ func chromedpExec(timeout time.Duration, debug bool, fn func(exec navigateExec) navCtx, navCancel := context.WithTimeout(tabCtx, timeout) defer navCancel() - var result browserAutomationResult - if err := chromedp.Run(navCtx, - chromedp.Navigate(url), - chromedp.Poll( - browserAutomationReadyScript, - nil, - chromedp.WithPollingInterval(100*time.Millisecond), - chromedp.WithPollingTimeout(0), - ), - chromedp.Evaluate(browserAutomationResultScript, &result), - ); err != nil { + if err := chromedp.Run(navCtx, chromedp.Navigate(url)); err != nil { + return fmt.Errorf("navigate to browser automation page: %w", err) + } + result, err := waitForBrowserAutomation(navCtx, 100*time.Millisecond, evaluateBrowserAutomation) + if err != nil { return fmt.Errorf("wait for browser automation status: %w", err) } if result.Status == "error" { @@ -506,6 +510,55 @@ func chromedpExec(timeout time.Duration, debug bool, fn func(exec navigateExec) return fn(exec) } +// waitForBrowserAutomation polls from a fresh page execution context after +// navigation so a redirect cannot invalidate the complete wait operation. +func waitForBrowserAutomation( + ctx context.Context, + interval time.Duration, + evaluate browserAutomationEvaluator, +) (browserAutomationResult, error) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + var lastResult browserAutomationResult + for { + var result browserAutomationResult + err := evaluate(ctx, &result) + if err == nil { + lastResult = result + if result.Status == "complete" || result.Status == "error" { + return result, nil + } + } else if !isTargetNavigationError(err) { + return browserAutomationResult{}, err + } + + select { + case <-ctx.Done(): + return browserAutomationResult{}, fmt.Errorf( + "%w (last status %q at %q)", + ctx.Err(), + lastResult.Status, + lastResult.URL, + ) + case <-ticker.C: + } + } +} + +// evaluateBrowserAutomation reads the current page's render status. +func evaluateBrowserAutomation(ctx context.Context, result *browserAutomationResult) error { + return chromedp.Run(ctx, chromedp.Evaluate(browserAutomationResultScript, result)) +} + +// isTargetNavigationError reports the Chrome event emitted when a page +// navigation replaces the JavaScript execution context used for one poll. +func isTargetNavigationError(err error) bool { + var protocolError *cdproto.Error + return errors.As(err, &protocolError) && + protocolError.Code == -32000 && + protocolError.Message == "Inspected target navigated or closed" +} + func loadDesign(pkg string, debug bool) (*mdl.Design, error) { b, err := codegen.JSON(pkg, debug) if err != nil { diff --git a/cmd/mdl/main_test.go b/cmd/mdl/main_test.go index 2a2b428b..40a2fb92 100644 --- a/cmd/mdl/main_test.go +++ b/cmd/mdl/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -9,6 +10,8 @@ import ( "testing" "time" + "github.com/chromedp/cdproto" + "goa.design/model/mdl" ) @@ -87,6 +90,51 @@ func TestNormalizeLayoutDirection(t *testing.T) { } } +func TestBrowserAutomationURL(t *testing.T) { + actual := browserAutomationURL( + "http://127.0.0.1:8080", + "AURA Services & Runtime", + "RIGHT", + true, + ) + expected := "http://127.0.0.1:8080/?auto=1&compact=1&direction=RIGHT&id=AURA+Services+%26+Runtime&save=1" + if actual != expected { + t.Fatalf("expected %q, got %q", expected, actual) + } +} + +func TestWaitForBrowserAutomationRetriesNavigation(t *testing.T) { + attempts := 0 + evaluate := func(_ context.Context, result *browserAutomationResult) error { + attempts++ + switch attempts { + case 1: + return &cdproto.Error{ + Code: -32000, + Message: "Inspected target navigated or closed", + } + case 2: + result.Status = "running" + default: + result.Status = "complete" + } + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := waitForBrowserAutomation(ctx, time.Millisecond, evaluate) + if err != nil { + t.Fatalf("wait for browser automation: %v", err) + } + if result.Status != "complete" { + t.Fatalf("expected complete status, got %q", result.Status) + } + if attempts != 3 { + t.Fatalf("expected three status checks, got %d", attempts) + } +} + func TestChromedpExecReportsBrowserAutomationError(t *testing.T) { if !hasChrome() { t.Skip("skipping: Chrome/Chromium not available in PATH")